Initialer Push, Grundsätzlich funktioniert das Programm

This commit is contained in:
2026-07-09 23:00:31 +02:00
parent 70088f7861
commit 952bbb6158
51 changed files with 3066 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.egg-info/
.venv/
venv/
build/
dist/
Executable
+81
View File
@@ -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 <<EOF
Installation abgeschlossen.
Notarius ist jetzt im Anwendungsmenü zu finden, oder direkt startbar mit:
$VENV_DIR/bin/notarius
Autostart, API-Key und Modellwahl lassen sich im Tab "Settings" konfigurieren.
Zum Entfernen: ./uninstall.sh
EOF
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "notarius"
version = "0.1.0"
description = "Lokale Aufnahme, Transkription und KI-Zusammenfassung von Gesprächen"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"PySide6>=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"]
+54
View File
@@ -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
+65
View File
@@ -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 <audiodatei> [...]")
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!")
+5
View File
@@ -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
+65
View File
@@ -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 <audiodatei> [...]")
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!")
+66
View File
@@ -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!")
+94
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Notarius: lokale Aufnahme, Transkription und KI-Zusammenfassung von Gesprächen."""
__version__ = "0.1.0"
+13
View File
@@ -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()
+57
View File
@@ -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())
View File
+40
View File
@@ -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")
+35
View File
@@ -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
+87
View File
@@ -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
+78
View File
@@ -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)
+41
View File
@@ -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()
+63
View File
@@ -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)
View File
+115
View File
@@ -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)
+21
View File
@@ -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
+98
View File
@@ -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()
View File
+142
View File
@@ -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("<b>Instructions and Notes</b>"))
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"<b>Titel:</b> {recording.meta.title}<br>"
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(recording.meta.created_at)}<br>"
f"<b>Länge:</b> {format_duration(recording.meta.duration_seconds)}<br>"
f"<b>Sprecher:</b> {', '.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")
+91
View File
@@ -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}")
+146
View File
@@ -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
+82
View File
@@ -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"<b>Titel:</b> {recording.meta.title}<br>"
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(recording.meta.created_at)}<br>"
f"<b>Länge:</b> {format_duration(recording.meta.duration_seconds)}<br>"
f"<b>Sprecher:</b> {', '.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}")
+192
View File
@@ -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"<b>Länge:</b> {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)
+79
View File
@@ -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)
+67
View File
@@ -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)}")
+120
View File
@@ -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"<b>{self._label_prefix}:</b> {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"<b>{self._label_prefix}:</b> {dt.strftime(self._DISPLAY_FORMAT)}")
self._edit_field.setDateTime(dt)
except ValueError:
self._display_label.setText(f"<b>{self._label_prefix}:</b> {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)
+73
View File
@@ -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
+40
View File
@@ -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)
+51
View File
@@ -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()
View File
+189
View File
@@ -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 `<basisname>.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 `<basisname>.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"
+35
View File
@@ -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)
+46
View File
@@ -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()
+84
View File
@@ -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()
@@ -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.
+63
View File
@@ -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)
+29
View File
@@ -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)
+100
View File
@@ -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
+98
View File
@@ -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:<name> - neue Verarbeitungsstufe erreicht
ERROR:<message> - 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()
+30
View File
@@ -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"
+102
View File
@@ -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)
Executable
+22
View File
@@ -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."