From ce67c46bfff4875c1f71e0c61d3dba7ecd07b910 Mon Sep 17 00:00:00 2001 From: captain nemorg Date: Fri, 10 Jul 2026 00:02:02 +0200 Subject: [PATCH] Player & Titel-Aktualisierung implementiert --- reference/call-recorder.sh | 54 ----------- reference/transkribiere_datei.py | 65 ------------- reference/transkribiere_datei.sh | 5 - reference/transkribieren.py | 65 ------------- reference/transkribieren_1.py | 66 ------------- reference/zusammenfassung_prompt.md | 94 ------------------- src/notarius/audio/recorder.py | 4 +- src/notarius/desktop_notify.py | 30 ++++++ src/notarius/gui/main_window.py | 8 ++ src/notarius/gui/tabs/processing_tab.py | 21 +++-- src/notarius/gui/tabs/summary_tab.py | 20 ++-- src/notarius/gui/tabs/transcript_tab.py | 7 ++ src/notarius/gui/tray.py | 40 ++++++-- src/notarius/gui/widgets/audio_player.py | 32 ++++++- src/notarius/rec_cli.py | 28 ++++-- .../resources/zusammenfassung_prompt.md | 21 ++++- src/notarius/summarization/client.py | 5 +- src/notarius/summarization/prompts.py | 26 ++++- src/notarius/summarization/worker.py | 6 +- 19 files changed, 206 insertions(+), 391 deletions(-) delete mode 100644 reference/call-recorder.sh delete mode 100644 reference/transkribiere_datei.py delete mode 100755 reference/transkribiere_datei.sh delete mode 100644 reference/transkribieren.py delete mode 100644 reference/transkribieren_1.py delete mode 100644 reference/zusammenfassung_prompt.md create mode 100644 src/notarius/desktop_notify.py diff --git a/reference/call-recorder.sh b/reference/call-recorder.sh deleted file mode 100644 index 3ab4614..0000000 --- a/reference/call-recorder.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# ~/bin/call-recorder.sh -set -euo pipefail - -REC_DIR="$HOME" -PIDFILE="/tmp/call-recorder.pid" -LOGFILE="/tmp/call-recorder.log" - -start_recording() { - if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then - notify-send "Call Recorder" "Läuft bereits (PID $(cat "$PIDFILE"))" - exit 1 - fi - - local sink source outfile - sink="$(pactl get-default-sink).monitor" - source="$(pactl get-default-source)" - outfile="${REC_DIR}/call_$(date +%Y-%m-%d_%H-%M-%S).mp3" - - ffmpeg -nostdin -y \ - -f pulse -i "$sink" \ - -f pulse -i "$source" \ - -filter_complex "[0:a]pan=mono|c0=0.5*c0+0.5*c1[browser];[1:a]pan=mono|c0=c0[mic];[browser][mic]amerge=inputs=2[aout]" \ - -map "[aout]" -ac 2 -c:a libmp3lame -q:a 2 \ - "$outfile" \ - > "$LOGFILE" 2>&1 & - - echo $! > "$PIDFILE" - notify-send "Call Recorder" "Aufnahme gestartet: $(basename "$outfile")" -} - -stop_recording() { - if [[ ! -f "$PIDFILE" ]]; then - notify-send "Call Recorder" "Keine laufende Aufnahme." - exit 1 - fi - local pid - pid="$(cat "$PIDFILE")" - kill -INT "$pid" 2>/dev/null || true - # ffmpeg braucht einen Moment, um den MP3-Frame sauber abzuschließen - for _ in {1..20}; do - kill -0 "$pid" 2>/dev/null || break - sleep 0.2 - done - rm -f "$PIDFILE" - notify-send "Call Recorder" "Aufnahme gestoppt." -} - -case "${1:-toggle}" in - start) start_recording ;; - stop) stop_recording ;; - toggle) if [[ -f "$PIDFILE" ]]; then stop_recording; else start_recording; fi ;; - *) echo "Usage: $0 {start|stop|toggle}"; exit 1 ;; -esac \ No newline at end of file diff --git a/reference/transkribiere_datei.py b/reference/transkribiere_datei.py deleted file mode 100644 index 977c1ce..0000000 --- a/reference/transkribiere_datei.py +++ /dev/null @@ -1,65 +0,0 @@ -import whisperx -import gc -import os -import sys - -HF_TOKEN="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" -DEVICE = "cuda" -COMPUTE_TYPE = "float16" -BATCH_SIZE = 4 - -if len(sys.argv) < 2: - print("Verwendung: python transkribiere_datei.py [...]") - sys.exit(1) - -audio_files = sys.argv[1:] -print(f"{len(audio_files)} Datei(en) zu transkribieren\n") - -print("Lade Whisper large-v3...") -model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") - -print("Lade Diarization-Pipeline...") -diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) - -for audio_path in audio_files: - base = os.path.splitext(audio_path)[0] - out_path = base + ".txt" - fname = os.path.basename(audio_path) - - print(f"\n{'='*60}") - print(f"Verarbeite: {fname}") - print(f"{'='*60}") - - audio = whisperx.load_audio(audio_path) - - print(" 1/4 Transkription...") - result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") - - print(" 2/4 Alignment...") - model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) - result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, - return_char_alignments=False) - del model_a; gc.collect() - - print(" 3/4 Speaker Diarization...") - diarize_segments = diarize_model(audio) - result = whisperx.assign_word_speakers(diarize_segments, result) - - print(" 4/4 Speichere Transkript...") - with open(out_path, "w", encoding="utf-8") as f: - f.write(f"Transkript: {fname}\n") - f.write("=" * 60 + "\n\n") - current_speaker = None - for seg in result["segments"]: - speaker = seg.get("speaker", "UNBEKANNT") - text = seg["text"].strip() - start = seg["start"] - end = seg["end"] - if speaker != current_speaker: - f.write(f"\n[{speaker}]\n") - current_speaker = speaker - f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") - - print(f" -> Gespeichert: {out_path}") - -print("\nAlle Dateien fertig!") diff --git a/reference/transkribiere_datei.sh b/reference/transkribiere_datei.sh deleted file mode 100755 index 65cfd9f..0000000 --- a/reference/transkribiere_datei.sh +++ /dev/null @@ -1,5 +0,0 @@ -source /home/timbfrank/whisperx-env/bin/activate -python /home/timbfrank/transkribiere_datei.py "$@" -echo "" -echo "Fertig! Fenster schließt in 10 Sekunden..." -sleep 10 diff --git a/reference/transkribieren.py b/reference/transkribieren.py deleted file mode 100644 index 9c149de..0000000 --- a/reference/transkribieren.py +++ /dev/null @@ -1,65 +0,0 @@ -import whisperx -import gc -import os -import sys - -HF_TOKEN = "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" -DEVICE = "cuda" -COMPUTE_TYPE = "float16" -BATCH_SIZE = 4 - -if len(sys.argv) < 2: - print("Verwendung: python transkribiere_datei.py [...]") - sys.exit(1) - -audio_files = sys.argv[1:] -print(f"{len(audio_files)} Datei(en) zu transkribieren\n") - -print("Lade Whisper large-v3...") -model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") - -print("Lade Diarization-Pipeline...") -diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) - -for audio_path in audio_files: - base = os.path.splitext(audio_path)[0] - out_path = base + ".txt" - fname = os.path.basename(audio_path) - - print(f"\n{'='*60}") - print(f"Verarbeite: {fname}") - print(f"{'='*60}") - - audio = whisperx.load_audio(audio_path) - - print(" 1/4 Transkription...") - result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") - - print(" 2/4 Alignment...") - model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) - result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, - return_char_alignments=False) - del model_a; gc.collect() - - print(" 3/4 Speaker Diarization...") - diarize_segments = diarize_model(audio) - result = whisperx.assign_word_speakers(diarize_segments, result) - - print(" 4/4 Speichere Transkript...") - with open(out_path, "w", encoding="utf-8") as f: - f.write(f"Transkript: {fname}\n") - f.write("=" * 60 + "\n\n") - current_speaker = None - for seg in result["segments"]: - speaker = seg.get("speaker", "UNBEKANNT") - text = seg["text"].strip() - start = seg["start"] - end = seg["end"] - if speaker != current_speaker: - f.write(f"\n[{speaker}]\n") - current_speaker = speaker - f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") - - print(f" -> Gespeichert: {out_path}") - -print("\nAlle Dateien fertig!") diff --git a/reference/transkribieren_1.py b/reference/transkribieren_1.py deleted file mode 100644 index 7525609..0000000 --- a/reference/transkribieren_1.py +++ /dev/null @@ -1,66 +0,0 @@ -import whisperx -import gc -import os - -HF_TOKEN = "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" -DEVICE = "cuda" -COMPUTE_TYPE = "float16" -BATCH_SIZE = 4 - -AUDIO_DIR = os.path.expanduser( - "~/VRtual X Dropbox/Tim B. Frank/Apps/Tims Hermes/Projekte/battenfeld-cincinnati/2026-06-30_Audionotizen" -) -OUTPUT_DIR = os.path.expanduser("~/Transkripte/battenfeld-cincinnati") -os.makedirs(OUTPUT_DIR, exist_ok=True) - -files = sorted([f for f in os.listdir(AUDIO_DIR) if f.endswith(".mp3")]) -print(f"{len(files)} Dateien gefunden: {files}\n") - -print("Lade Whisper large-v3...") -model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de") - -print("Lade Diarization-Pipeline...") -diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE) - -for fname in files: - audio_path = os.path.join(AUDIO_DIR, fname) - out_name = fname.replace(".mp3", ".txt") - out_path = os.path.join(OUTPUT_DIR, out_name) - - print(f"\n{'='*60}") - print(f"Verarbeite: {fname}") - print(f"{'='*60}") - - audio = whisperx.load_audio(audio_path) - - print(" 1/4 Transkription...") - result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de") - - print(" 2/4 Alignment...") - model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE) - result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE, - return_char_alignments=False) - del model_a; gc.collect() - - print(" 3/4 Speaker Diarization...") - diarize_segments = diarize_model(audio) - result = whisperx.assign_word_speakers(diarize_segments, result) - - print(" 4/4 Speichere Transkript...") - with open(out_path, "w", encoding="utf-8") as f: - f.write(f"Transkript: {fname}\n") - f.write("="*60 + "\n\n") - current_speaker = None - for seg in result["segments"]: - speaker = seg.get("speaker", "UNBEKANNT") - text = seg["text"].strip() - start = seg["start"] - end = seg["end"] - if speaker != current_speaker: - f.write(f"\n[{speaker}]\n") - current_speaker = speaker - f.write(f" [{start:6.1f}s – {end:5.1f}s] {text}\n") - - print(f" -> Gespeichert: {out_path}") - -print("\nAlle Dateien fertig!") diff --git a/reference/zusammenfassung_prompt.md b/reference/zusammenfassung_prompt.md deleted file mode 100644 index 4668565..0000000 --- a/reference/zusammenfassung_prompt.md +++ /dev/null @@ -1,94 +0,0 @@ - -HERMES: -Hier der vollständige Inhalt: - ---- - -Trigger-Keywords: -- transkript verarbeiten -- gesprächsprotokoll -- gespräch aufbereiten -- meeting zusammenfassen -- kundengespräch - ---- - -Pflicht: Fehlende Infos zuerst klären - -Bevor du mit der Verarbeitung beginnst, prüfe ob folgende Infos vorhanden sind. Wenn nicht → explizit nachfragen: - -- Datum des Gesprächs -- Gesprächsort / Kanal (Telefon, vor Ort, Video) -- Gesprächsteilnehmer (Name + Rolle / Unternehmen) — wer spricht für wen? -- Projektzuordnung (welches Projekt oder welche Kampagne?) -- Falls Personen im Transkript nicht namentlich zugeordnet sind: Wer ist wer? - -Erst wenn diese Infos vollständig sind, mit dem Template beginnen. - ---- - -Output-Template - -Ausgabe immer in sauberem Markdown. Dateiname-Vorschlag: YYYY-MM-DD_Gesprächsprotokoll_[Kundenname].md - -# Gesprächsprotokoll – [Kundenname / Projekt] - ---- - -## Basisinfos -- **Datum:** -- **Kanal:** (Telefon / vor Ort / Video) -- **Ort / Plattform:** -- **Teilnehmer:** - - [Name] – [Rolle], [Unternehmen] - - [Name] – [Rolle], [Unternehmen] -- **Projekt / Kontext:** -- **Gesprächstyp:** (Erstgespräch / Follow-up / Demo / Projektgespräch / Abschluss) - ---- - -## Gesprächszusammenfassung -[Fließtext, maximal zwei Absätze. Die wichtigsten Themen, Stimmung, roter Faden des Gesprächs. Kein Aufzählungs-Stil — echte Prosa.] - ---- - -## Entscheidungen & Fakten - -**Getroffene Entscheidungen:** -- [Entscheidung, ggf. mit wer entschieden hat] - -**Genannte Fakten & Rahmenbedingungen:** -- [Budget, Timeline, Teamgröße, technische Vorgaben, Vertragsinfos etc.] - ---- - -## ToDos & Nächste Schritte -- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] -- [ ] [Aufgabe] — [Person / Unternehmen] — [Datum falls genannt] - ---- - -## Nebeninformationen *(intern, nicht zur Weitergabe)* -- [Interessante Kontextinfos, persönliche Details, Brancheninfos, die zufällig erwähnt wurden — nicht projektbezogen, aber möglicherweise nützlich] - - ---- - -Verarbeitungshinweise - -1. Transkript-Input: Kann roher Text, korrigiertes Transkript oder Stichpunkte sein. -2. Sprache: Immer auf Deutsch ausgeben, auch wenn das Transkript teilweise Englisch enthält. -3. Block 2: Kein Marketing-Sprech, kein „stell dir vor" — sachlich, direkt, wie Tim es formulieren würde. -4. Block 4: Format [ ] Aufgabe — Person/Unternehmen — Datum konsequent einhalten. Wenn keine Person zuordenbar → [ ] Aufgabe — unklar, klären -5. Block 5: Nur wirklich interessante Nebenfakten, keine Füller. Lieber leer lassen als auffüllen. -6. Datei speichern: Wenn möglich, als .md-Datei unter einem sinnvollen Pfad ablegen (z.B. Obsidian-Vault oder lokaler Projektordner). Tim entscheidet ob er sie weiterleitet. - ---- - -Typischer Workflow - -1. Tim gibt Transkript-Text (oder Datei) + ggf. Kontextinfos -2. Fehlende Pflichtinfos abfragen -3. 5-Block-Template befüllen -4. Als Markdown-Datei ausgeben (MEDIA-Link wenn lokal gespeichert) -5. Tim kann Block 5 für sich behalten und den Rest weitergeben diff --git a/src/notarius/audio/recorder.py b/src/notarius/audio/recorder.py index e47bf40..500bc24 100644 --- a/src/notarius/audio/recorder.py +++ b/src/notarius/audio/recorder.py @@ -61,7 +61,9 @@ class CallRecorder: "-f", "pulse", "-i", source, "-filter_complex", filter_complex, "-map", "[aout]", "-ac", "2", - "-c:a", "libmp3lame", "-q:a", "2", + # Feste Bitrate statt VBR-Qualitätsstufe: Datenmenge spielt keine Rolle, + # 320k ist die maximal mögliche MP3-Bitrate. + "-c:a", "libmp3lame", "-b:a", "320k", str(output_path), ] diff --git a/src/notarius/desktop_notify.py b/src/notarius/desktop_notify.py new file mode 100644 index 0000000..1e13205 --- /dev/null +++ b/src/notarius/desktop_notify.py @@ -0,0 +1,30 @@ +"""Desktop-Benachrichtigungen über `notify-send` (freedesktop.org Notifications). + +Wird von notarius-rec verwendet: dieser Befehl läuft typischerweise über einen +KDE-Tastenkurzbefehl ganz ohne sichtbares Terminal. Ohne eine Benachrichtigung +bekäme der Nutzer nie mit, ob Start/Stop tatsächlich geklappt hat oder woran ein +Fehlschlag lag (z.B. fehlender Standard-Sink) - stdout/stderr laufen in diesem +Kontext ins Leere. Die Notarius-GUI selbst nutzt stattdessen QSystemTrayIcon, +da sie ohnehin eine Qt-Event-Loop hat. +""" + +from __future__ import annotations + +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +def notify(title: str, message: str, urgency: str = "normal") -> None: + """Zeigt eine Desktop-Benachrichtigung. Fehlt notify-send, wird nur geloggt - + das darf niemals den eigentlichen Aufnahme-Befehl zum Absturz bringen. + """ + try: + subprocess.run( + ["notify-send", "--urgency", urgency, "--app-name", "Notarius", title, message], + check=False, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + logger.warning("notify-send nicht verfügbar, Benachrichtigung übersprungen: %s - %s", title, message) diff --git a/src/notarius/gui/main_window.py b/src/notarius/gui/main_window.py index 8078ba6..905542b 100644 --- a/src/notarius/gui/main_window.py +++ b/src/notarius/gui/main_window.py @@ -53,6 +53,7 @@ class MainWindow(QMainWindow): self._recordings_tab.recording_selected.connect(self._on_recording_selected) self._transcript_tab.transcription_finished.connect(self._on_transcription_finished) + self._transcript_tab.metadata_changed.connect(self._on_metadata_changed) self._processing_tab.summarization_finished.connect(self._on_summarization_finished) self._update_tab_states() @@ -72,6 +73,13 @@ class MainWindow(QMainWindow): self._update_tab_states() self._processing_tab.set_recording(self._selected_recording) + def _on_metadata_changed(self) -> None: + # Tab 2 hat Titel/Zeitstempel/Sprecher direkt auf dem geteilten Recording-Objekt + # aktualisiert - Tab 3/4 müssen nur ihre (read-only) Kopfzeile neu zeichnen, + # nicht ihre editierbaren Textfelder neu laden. + self._processing_tab.refresh_header() + self._summary_tab.refresh_header() + def _on_summarization_finished(self) -> None: self._summary_tab.set_recording(self._selected_recording) self._update_tab_states() diff --git a/src/notarius/gui/tabs/processing_tab.py b/src/notarius/gui/tabs/processing_tab.py index 90d2fb6..43b26a3 100644 --- a/src/notarius/gui/tabs/processing_tab.py +++ b/src/notarius/gui/tabs/processing_tab.py @@ -65,12 +65,7 @@ class ProcessingTab(QWidget): if not has_recording: return - self._header_label.setText( - f"Titel: {recording.meta.title}
" - f"Aufzeichnungszeitpunkt: {format_timestamp(recording.meta.created_at)}
" - f"Länge: {format_duration(recording.meta.duration_seconds)}
" - f"Sprecher: {', '.join(recording.meta.speakers) or '-'}" - ) + self.refresh_header() if recording.prompt_notes_path.exists(): self._notes_edit.setPlainText(recording.prompt_notes_path.read_text(encoding="utf-8")) @@ -81,6 +76,19 @@ class ProcessingTab(QWidget): self._summarize_button.setText("Summarize") self._summarize_button.setEnabled(True) + def refresh_header(self) -> None: + """Aktualisiert nur die Kopfzeile (z.B. nach einer Titeländerung in Tab 2), + ohne Notizen/Status anzurühren - vermeidet den Verlust unsaved Änderungen. + """ + if self._recording is None: + return + self._header_label.setText( + f"Titel: {self._recording.meta.title}
" + f"Aufzeichnungszeitpunkt: {format_timestamp(self._recording.meta.created_at)}
" + f"Länge: {format_duration(self._recording.meta.duration_seconds)}
" + f"Sprecher: {', '.join(self._recording.meta.speakers) or '-'}" + ) + def _on_notes_save_clicked(self) -> None: if self._recording is None: return @@ -119,6 +127,7 @@ class ProcessingTab(QWidget): self._controller.config.claude_model, transcript_markdown, notes, + self._recording.meta.speakers, self._on_summary_ok, self._on_summary_error, self._on_summary_cancelled, diff --git a/src/notarius/gui/tabs/summary_tab.py b/src/notarius/gui/tabs/summary_tab.py index 11bc47c..d44d00a 100644 --- a/src/notarius/gui/tabs/summary_tab.py +++ b/src/notarius/gui/tabs/summary_tab.py @@ -48,18 +48,26 @@ class SummaryTab(QWidget): if not has_recording: return - self._header_label.setText( - f"Titel: {recording.meta.title}
" - f"Aufzeichnungszeitpunkt: {format_timestamp(recording.meta.created_at)}
" - f"Länge: {format_duration(recording.meta.duration_seconds)}
" - f"Sprecher: {', '.join(recording.meta.speakers) or '-'}" - ) + self.refresh_header() if recording.has_summary: self._text_edit.setPlainText(recording.summary_path.read_text(encoding="utf-8")) else: self._text_edit.clear() + def refresh_header(self) -> None: + """Aktualisiert nur die Kopfzeile (z.B. nach einer Titeländerung in Tab 2), + ohne den Summary-Text anzurühren - vermeidet den Verlust unsaved Änderungen. + """ + if self._recording is None: + return + self._header_label.setText( + f"Titel: {self._recording.meta.title}
" + f"Aufzeichnungszeitpunkt: {format_timestamp(self._recording.meta.created_at)}
" + f"Länge: {format_duration(self._recording.meta.duration_seconds)}
" + f"Sprecher: {', '.join(self._recording.meta.speakers) or '-'}" + ) + def _on_save_clicked(self) -> None: if self._recording is None: return diff --git a/src/notarius/gui/tabs/transcript_tab.py b/src/notarius/gui/tabs/transcript_tab.py index 32121a8..1f48bf4 100644 --- a/src/notarius/gui/tabs/transcript_tab.py +++ b/src/notarius/gui/tabs/transcript_tab.py @@ -31,6 +31,10 @@ class TranscriptTab(QWidget): # daraufhin erneut, ob Tab 3 aktiviert werden kann. transcription_finished = Signal() + # Titel/Zeitstempel/Sprecher wurden geändert - MainWindow aktualisiert daraufhin + # nur die (read-only) Kopfzeilen in Tab 3/4, ohne deren Textinhalte anzurühren. + metadata_changed = Signal() + def __init__(self, controller: AppController, parent: QWidget | None = None) -> None: super().__init__(parent) self._controller = controller @@ -115,6 +119,7 @@ class TranscriptTab(QWidget): self._recording.meta.title = new_title self._recording.save_meta() self._controller.recordings_changed.emit() + self.metadata_changed.emit() def _on_timestamp_changed(self, new_iso: str) -> None: if self._recording is None: @@ -122,6 +127,7 @@ class TranscriptTab(QWidget): self._recording.meta.created_at = new_iso self._recording.save_meta() self._controller.recordings_changed.emit() + self.metadata_changed.emit() def _on_speakers_edited(self) -> None: if self._recording is None: @@ -129,6 +135,7 @@ class TranscriptTab(QWidget): speakers = [s.strip() for s in self._speaker_field.text().split(",") if s.strip()] self._recording.meta.speakers = speakers self._recording.save_meta() + self.metadata_changed.emit() # -- Transkription ------------------------------------------------------------- diff --git a/src/notarius/gui/tray.py b/src/notarius/gui/tray.py index d9bf8e9..866afd7 100644 --- a/src/notarius/gui/tray.py +++ b/src/notarius/gui/tray.py @@ -8,22 +8,44 @@ from __future__ import annotations import logging -from PySide6.QtGui import QAction, QIcon -from PySide6.QtWidgets import QApplication, QMenu, QStyle, QSystemTrayIcon +from PySide6.QtCore import QRectF, Qt +from PySide6.QtGui import QAction, QColor, QCursor, QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon from notarius.gui.controller import AppController logger = logging.getLogger(__name__) +# Bewusst schlichte, farbige Punkte statt Systemthemen-Icons: ein Standard-Icon +# wie "Lautstärke" oder "Media-Stop" lässt sich im Tray leicht mit anderen +# Anwendungen verwechseln. Grün/Rot ist eindeutig und sofort erkennbar. +_IDLE_COLOR = QColor("#2ecc71") +_RECORDING_COLOR = QColor("#e74c3c") + + +def _make_dot_icon(color: QColor, size: int = 64) -> QIcon: + """Zeichnet einen einfarbigen, kräftigen Kreis als Tray-Icon.""" + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setBrush(color) + painter.setPen(Qt.PenStyle.NoPen) + margin = size * 0.1 + painter.drawEllipse(QRectF(margin, margin, size - 2 * margin, size - 2 * margin)) + painter.end() + + return QIcon(pixmap) + 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._icon_idle = _make_dot_icon(_IDLE_COLOR) + self._icon_recording = _make_dot_icon(_RECORDING_COLOR) self.setIcon(self._icon_idle) self._menu = QMenu() @@ -54,11 +76,11 @@ class TrayIcon(QSystemTrayIcon): return self._open_action def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: - # Linksklick öffnet ebenfalls das Kontextmenü (Trigger = Standard-Klick auf das Icon). + # Trigger = Linksklick auf das Icon. self.geometry() liefert unter KDE/Plasma + # (StatusNotifierItem-Protokoll) meist ein leeres Rechteck - deshalb an der + # tatsächlichen Cursor-Position öffnen statt an einer geratenen Fallback-Position. if reason == QSystemTrayIcon.ActivationReason.Trigger: - self.contextMenu().popup( - self.geometry().topLeft() if not self.geometry().isNull() else self.contextMenu().pos() - ) + self.contextMenu().popup(QCursor.pos()) def _on_recording_state_changed(self, is_recording: bool) -> None: if is_recording: diff --git a/src/notarius/gui/widgets/audio_player.py b/src/notarius/gui/widgets/audio_player.py index 6286838..71ea4be 100644 --- a/src/notarius/gui/widgets/audio_player.py +++ b/src/notarius/gui/widgets/audio_player.py @@ -2,11 +2,14 @@ from __future__ import annotations +import logging 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 +from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QSlider, QVBoxLayout, QWidget + +logger = logging.getLogger(__name__) def _format_ms(milliseconds: int) -> str: @@ -24,6 +27,7 @@ class MiniAudioPlayer(QWidget): self._player = QMediaPlayer(self) self._audio_output = QAudioOutput(self) + self._audio_output.setVolume(1.0) self._player.setAudioOutput(self._audio_output) self._play_button = QPushButton("▶") @@ -35,17 +39,28 @@ class MiniAudioPlayer(QWidget): self._time_label = QLabel("00:00 / 00:00") - layout = QHBoxLayout(self) + controls_row = QHBoxLayout() + controls_row.setContentsMargins(0, 0, 0, 0) + controls_row.addWidget(self._play_button) + controls_row.addWidget(self._position_slider, stretch=1) + controls_row.addWidget(self._time_label) + + self._error_label = QLabel() + self._error_label.setStyleSheet("color: #e74c3c;") + self._error_label.setVisible(False) + + layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._play_button) - layout.addWidget(self._position_slider, stretch=1) - layout.addWidget(self._time_label) + layout.addLayout(controls_row) + layout.addWidget(self._error_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) + self._player.errorOccurred.connect(self._on_error_occurred) def load(self, audio_path: Path) -> None: + self._error_label.setVisible(False) self._player.stop() self._player.setSource(QUrl.fromLocalFile(str(audio_path))) @@ -65,3 +80,10 @@ class MiniAudioPlayer(QWidget): 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)}") + + def _on_error_occurred(self, error: QMediaPlayer.Error, error_string: str) -> None: + # Ohne diese Anzeige würde eine fehlgeschlagene Wiedergabe (z.B. fehlender + # Audio-Ausgang, kaputte Datei) nur als stille Untätigkeit wahrgenommen. + logger.error("Wiedergabefehler: %s", error_string) + self._error_label.setText(f"Wiedergabefehler: {error_string}") + self._error_label.setVisible(True) diff --git a/src/notarius/rec_cli.py b/src/notarius/rec_cli.py index 05550b1..353b097 100644 --- a/src/notarius/rec_cli.py +++ b/src/notarius/rec_cli.py @@ -4,16 +4,28 @@ ob die Notarius-GUI läuft - gedacht zum Binden an einen KDE-Tastenkurzbefehl `call-recorder.sh toggle` aus reference/. Importiert bewusst kein PySide6, damit der Aufruf ohne GUI-Overhead sofort reagiert. +Zeigt Desktop-Benachrichtigungen (siehe desktop_notify.py), da ein per +Tastenkurzbefehl ausgelöster Aufruf kein sichtbares Terminal hat - print() +allein würde der Nutzer nie zu Gesicht bekommen. """ from __future__ import annotations import argparse +import logging import sys from notarius import recording_session +from notarius.desktop_notify import notify from notarius.logging_setup import configure_logging +logger = logging.getLogger(__name__) + + +def _report(message: str, urgency: str = "normal") -> None: + print(message) + notify("Notarius", message, urgency) + def main() -> None: parser = argparse.ArgumentParser(description="Notarius-Aufnahme steuern (auch ohne laufende GUI)") @@ -25,20 +37,24 @@ def main() -> None: try: if args.action == "start": recording = recording_session.start() - print(f"Aufnahme gestartet: {recording.meta.title}") + _report(f"Aufnahme gestartet: {recording.meta.title}") elif args.action == "stop": recording = recording_session.stop() if recording is None: - print("Keine laufende Aufnahme.") + _report("Keine laufende Aufnahme.") else: - print(f"Aufnahme gestoppt: {recording.meta.title}") + _report(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) + _report(f"Aufnahme {verb}{title}") + except Exception as exc: # noqa: BLE001 - jeder Fehler muss dem Nutzer sichtbar gemacht werden + # Ohne diesen breiten Fang würde z.B. ein RecorderError (kein Standard-Sink, + # ffmpeg nicht startbar) unbehandelt durchschlagen - ohne Terminal verschwindet + # der Traceback dann spurlos, der Nutzer merkt nur "es passiert nichts". + logger.exception("notarius-rec fehlgeschlagen") + _report(f"Fehler: {exc}", urgency="critical") sys.exit(1) diff --git a/src/notarius/resources/zusammenfassung_prompt.md b/src/notarius/resources/zusammenfassung_prompt.md index 0f075e6..9c1aaa2 100644 --- a/src/notarius/resources/zusammenfassung_prompt.md +++ b/src/notarius/resources/zusammenfassung_prompt.md @@ -1,11 +1,22 @@ 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 +Du bekommst bis zu drei Texte: +1. Optional eine Liste "Bekannte Teilnehmer": Namen, die für dieses Gespräch + hinterlegt sind, aber NICHT fest den SPEAKER_00/SPEAKER_01-Labeln im Transkript + zugeordnet sind. Eine manuelle Zuordnungsfunktion gibt es im Programm noch nicht - + bis dahin ist das deine Aufgabe: Versuche anhand des Gesprächsinhalts (Anrede, + Selbstvorstellung, wer wen wie nennt, inhaltlicher Kontext) herauszufinden, welches + SPEAKER-Label zu welchem Namen gehört, und schreibe im Protokoll durchgängig die + echten Namen statt der SPEAKER-Labels. Ist eine eindeutige Zuordnung nicht möglich, + rate NICHT - nenne die Teilnehmer trotzdem namentlich im Feld "Teilnehmer" (ohne + feste Zuordnung zu einer Aussage) und arbeite im Fließtext weiterhin mit den + SPEAKER-Labeln. +2. 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 +3. 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. + sonstige Metadaten, die nicht aus dem Transkript allein hervorgehen. Falls hier + eine Zuordnung explizit genannt wird, hat sie Vorrang vor deiner eigenen Vermutung. Dies ist ein einmaliger, nicht-interaktiver Verarbeitungsschritt - du kannst nicht nachfragen. Wenn eine Pflichtangabe (Datum, Kanal, Teilnehmer) weder im Transkript @@ -67,3 +78,5 @@ Verarbeitungshinweise: 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. +5. Sprecherzuordnung: Siehe Punkt 1 oben - echte Namen verwenden, wo eindeutig + zuordenbar; sonst Teilnehmer trotzdem namentlich auflisten, ohne zu raten. diff --git a/src/notarius/summarization/client.py b/src/notarius/summarization/client.py index 8f8dd27..8c4e22b 100644 --- a/src/notarius/summarization/client.py +++ b/src/notarius/summarization/client.py @@ -37,16 +37,19 @@ def stream_summary( model: str, transcript_markdown: str, instructions_and_notes: str, + speakers: list[str], cancel_event: threading.Event, ) -> str: """Ruft die Anthropic-API streamend auf und baut den Volltext der Antwort zusammen. + `speakers` sind die in Tab 2 hinterlegten Namen (noch ohne feste Zuordnung zu + den SPEAKER_00/SPEAKER_01-Labeln) - das Modell versucht, sie selbst zuzuordnen. `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) + user_message = build_user_message(transcript_markdown, instructions_and_notes, speakers) chunks: list[str] = [] with client.messages.stream( diff --git a/src/notarius/summarization/prompts.py b/src/notarius/summarization/prompts.py index 9149451..0d9dfa5 100644 --- a/src/notarius/summarization/prompts.py +++ b/src/notarius/summarization/prompts.py @@ -14,13 +14,33 @@ def load_summary_system_prompt() -> str: ) -def build_user_message(transcript_markdown: str, instructions_and_notes: str) -> str: - """Kombiniert Transkript-Inhalt und Nutzer-Notizen zu einer einzigen User-Message. +def build_user_message( + transcript_markdown: str, instructions_and_notes: str, speakers: list[str] +) -> str: + """Kombiniert Transkript-Inhalt, bekannte Sprecher und Nutzer-Notizen zu einer + einzigen User-Message. + + Die Sprecherliste (aus Tab 2) enthält noch keine feste Zuordnung zu den + SPEAKER_00/SPEAKER_01-Labeln im Transkript - das Programm bietet dafür aktuell + keine manuelle Zuordnung an (kommt später). Bis dahin soll das Modell selbst + versuchen, die Labels anhand des Gesprächsinhalts den Namen zuzuordnen. 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()] + parts = [] + + if speakers: + parts += [ + "## Bekannte Teilnehmer", + "", + "Diese Namen sind für dieses Gespräch hinterlegt, aber NICHT fest den " + "SPEAKER_00/SPEAKER_01-Labeln im Transkript zugeordnet:", + ", ".join(speakers), + "", + ] + + parts += ["## Transkript", "", transcript_markdown.strip()] notes = instructions_and_notes.strip() if notes: diff --git a/src/notarius/summarization/worker.py b/src/notarius/summarization/worker.py index d4ca6dd..68ada6c 100644 --- a/src/notarius/summarization/worker.py +++ b/src/notarius/summarization/worker.py @@ -28,6 +28,7 @@ class _SummarizeThread(QThread): model: str, transcript_markdown: str, instructions_and_notes: str, + speakers: list[str], cancel_event: threading.Event, parent=None, ) -> None: @@ -36,6 +37,7 @@ class _SummarizeThread(QThread): self._model = model self._transcript_markdown = transcript_markdown self._instructions_and_notes = instructions_and_notes + self._speakers = speakers self._cancel_event = cancel_event def run(self) -> None: @@ -45,6 +47,7 @@ class _SummarizeThread(QThread): self._model, self._transcript_markdown, self._instructions_and_notes, + self._speakers, self._cancel_event, ) except SummarizationCancelled: @@ -73,6 +76,7 @@ class SummarizationWorker: model: str, transcript_markdown: str, instructions_and_notes: str, + speakers: list[str], on_finished_ok, on_finished_error, on_finished_cancelled, @@ -82,7 +86,7 @@ class SummarizationWorker: self._cancel_event = threading.Event() thread = _SummarizeThread( - api_key, model, transcript_markdown, instructions_and_notes, self._cancel_event + api_key, model, transcript_markdown, instructions_and_notes, speakers, self._cancel_event ) thread.finished_ok.connect(on_finished_ok) thread.finished_error.connect(on_finished_error)