Initialer Push, Grundsätzlich funktioniert das Programm
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
build/
|
||||
dist/
|
||||
Executable
+81
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
Executable
+5
@@ -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
|
||||
@@ -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!")
|
||||
@@ -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!")
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Notarius: lokale Aufnahme, Transkription und KI-Zusammenfassung von Gesprächen."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
@@ -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."
|
||||
Reference in New Issue
Block a user