Player & Titel-Aktualisierung implementiert

This commit is contained in:
2026-07-10 00:02:02 +02:00
parent 952bbb6158
commit ce67c46bff
19 changed files with 206 additions and 391 deletions
-54
View File
@@ -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
-65
View File
@@ -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 <audiodatei> [...]")
sys.exit(1)
audio_files = sys.argv[1:]
print(f"{len(audio_files)} Datei(en) zu transkribieren\n")
print("Lade Whisper large-v3...")
model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de")
print("Lade Diarization-Pipeline...")
diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE)
for audio_path in audio_files:
base = os.path.splitext(audio_path)[0]
out_path = base + ".txt"
fname = os.path.basename(audio_path)
print(f"\n{'='*60}")
print(f"Verarbeite: {fname}")
print(f"{'='*60}")
audio = whisperx.load_audio(audio_path)
print(" 1/4 Transkription...")
result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de")
print(" 2/4 Alignment...")
model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE)
result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE,
return_char_alignments=False)
del model_a; gc.collect()
print(" 3/4 Speaker Diarization...")
diarize_segments = diarize_model(audio)
result = whisperx.assign_word_speakers(diarize_segments, result)
print(" 4/4 Speichere Transkript...")
with open(out_path, "w", encoding="utf-8") as f:
f.write(f"Transkript: {fname}\n")
f.write("=" * 60 + "\n\n")
current_speaker = None
for seg in result["segments"]:
speaker = seg.get("speaker", "UNBEKANNT")
text = seg["text"].strip()
start = seg["start"]
end = seg["end"]
if speaker != current_speaker:
f.write(f"\n[{speaker}]\n")
current_speaker = speaker
f.write(f" [{start:6.1f}s {end:5.1f}s] {text}\n")
print(f" -> Gespeichert: {out_path}")
print("\nAlle Dateien fertig!")
-5
View File
@@ -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
-65
View File
@@ -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 <audiodatei> [...]")
sys.exit(1)
audio_files = sys.argv[1:]
print(f"{len(audio_files)} Datei(en) zu transkribieren\n")
print("Lade Whisper large-v3...")
model = whisperx.load_model("large-v3", DEVICE, compute_type=COMPUTE_TYPE, language="de")
print("Lade Diarization-Pipeline...")
diarize_model = whisperx.diarize.DiarizationPipeline(token=HF_TOKEN, device=DEVICE)
for audio_path in audio_files:
base = os.path.splitext(audio_path)[0]
out_path = base + ".txt"
fname = os.path.basename(audio_path)
print(f"\n{'='*60}")
print(f"Verarbeite: {fname}")
print(f"{'='*60}")
audio = whisperx.load_audio(audio_path)
print(" 1/4 Transkription...")
result = model.transcribe(audio, batch_size=BATCH_SIZE, language="de")
print(" 2/4 Alignment...")
model_a, metadata = whisperx.load_align_model(language_code="de", device=DEVICE)
result = whisperx.align(result["segments"], model_a, metadata, audio, DEVICE,
return_char_alignments=False)
del model_a; gc.collect()
print(" 3/4 Speaker Diarization...")
diarize_segments = diarize_model(audio)
result = whisperx.assign_word_speakers(diarize_segments, result)
print(" 4/4 Speichere Transkript...")
with open(out_path, "w", encoding="utf-8") as f:
f.write(f"Transkript: {fname}\n")
f.write("=" * 60 + "\n\n")
current_speaker = None
for seg in result["segments"]:
speaker = seg.get("speaker", "UNBEKANNT")
text = seg["text"].strip()
start = seg["start"]
end = seg["end"]
if speaker != current_speaker:
f.write(f"\n[{speaker}]\n")
current_speaker = speaker
f.write(f" [{start:6.1f}s {end:5.1f}s] {text}\n")
print(f" -> Gespeichert: {out_path}")
print("\nAlle Dateien fertig!")
-66
View File
@@ -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!")
-94
View File
@@ -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
+3 -1
View File
@@ -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),
]
+30
View File
@@ -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)
+8
View File
@@ -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()
+15 -6
View File
@@ -65,12 +65,7 @@ class ProcessingTab(QWidget):
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 '-'}"
)
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"<b>Titel:</b> {self._recording.meta.title}<br>"
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(self._recording.meta.created_at)}<br>"
f"<b>Länge:</b> {format_duration(self._recording.meta.duration_seconds)}<br>"
f"<b>Sprecher:</b> {', '.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,
+14 -6
View File
@@ -48,18 +48,26 @@ class SummaryTab(QWidget):
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 '-'}"
)
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"<b>Titel:</b> {self._recording.meta.title}<br>"
f"<b>Aufzeichnungszeitpunkt:</b> {format_timestamp(self._recording.meta.created_at)}<br>"
f"<b>Länge:</b> {format_duration(self._recording.meta.duration_seconds)}<br>"
f"<b>Sprecher:</b> {', '.join(self._recording.meta.speakers) or '-'}"
)
def _on_save_clicked(self) -> None:
if self._recording is None:
return
+7
View File
@@ -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 -------------------------------------------------------------
+31 -9
View File
@@ -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:
+27 -5
View File
@@ -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)
+22 -6
View File
@@ -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)
@@ -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.
+4 -1
View File
@@ -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(
+23 -3
View File
@@ -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:
+5 -1
View File
@@ -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)