esp32-claude-robbie/python_bridge/chat_audio_bridge.py

662 lines
24 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Claude's Eyes - Audio Bridge
Verbindet den echten Claude.ai Chat mit Audio (TTS/STT).
WICHTIG: Claude steuert den Roboter SELBST via web_fetch!
Diese Bridge macht NUR:
1. HEARTBEAT - Sendet [TICK] damit Claude "aufwacht"
2. TTS - Liest Claudes Antworten vor
3. STT - Hört auf Stefan und tippt seine Worte in den Chat
Das ist NICHT der alte API-Ansatz. ICH (Claude im Chat) bin der echte Claude
mit dem vollen Kontext unserer Gespräche!
Usage:
python chat_audio_bridge.py # Mit config.yaml
python chat_audio_bridge.py --config my.yaml # Eigene Config
python chat_audio_bridge.py --test # Nur testen
"""
import os
import sys
import time
import threading
import random
import re
import signal
import logging
from pathlib import Path
from typing import Optional
from dataclasses import dataclass
import yaml
import click
from rich.console import Console
from rich.panel import Panel
from rich.live import Live
from rich.table import Table
from rich.text import Text
from chat_web_interface import ClaudeChatInterface, ChatMessage
from tts_engine import create_tts_engine, TTSEngine
from stt_engine import create_stt_engine, STTEngine, SpeechResult
# Logging Setup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("bridge.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Rich Console für schöne Ausgabe
console = Console()
@dataclass
class BridgeStats:
"""Statistiken der Bridge"""
ticks_sent: int = 0
messages_spoken: int = 0
stefan_inputs: int = 0
errors: int = 0
consecutive_errors: int = 0 # Fehler in Folge
start_time: float = 0
class ClaudesEyesAudioBridge:
"""
Audio Bridge für Claude's Eyes.
Diese Klasse verbindet:
- Claude.ai Chat (Browser via Selenium)
- Text-to-Speech (Claudes Stimme)
- Speech-to-Text (Stefans Mikrofon)
Claude steuert den Roboter SELBST - wir machen nur den Audio-Teil!
"""
def __init__(self, config_path: str):
self.config = self._load_config(config_path)
self.running = False
self.stats = BridgeStats()
# Komponenten (werden in initialize() erstellt)
self.chat: Optional[ClaudeChatInterface] = None
self.tts: Optional[TTSEngine] = None
self.stt: Optional[STTEngine] = None
# State
self.last_assistant_message_id: Optional[str] = None
self._lock = threading.Lock()
# Ready-Flag: Heartbeat wartet bis Claude [READY] gesendet hat
self._claude_ready = threading.Event()
# Stefan-Buffer: Sammelt Spracheingaben während Claude tippt
self._stefan_buffer: list = []
self._stefan_buffer_lock = threading.Lock()
def _load_config(self, config_path: str) -> dict:
"""Lädt die Konfiguration"""
path = Path(config_path)
# Versuche .local Version zuerst
local_path = path.parent / f"{path.stem}.local{path.suffix}"
if local_path.exists():
path = local_path
logger.info(f"Nutze lokale Config: {path}")
if not path.exists():
logger.error(f"Config nicht gefunden: {path}")
sys.exit(1)
with open(path, 'r', encoding='utf-8') as f:
return yaml.safe_load(f)
def initialize(self) -> bool:
"""Initialisiert alle Komponenten"""
console.print(Panel.fit(
"[bold cyan]Claude's Eyes[/bold cyan]\n"
"[dim]Audio Bridge v2.0[/dim]\n\n"
"[yellow]ICH (Claude) steuere den Roboter selbst![/yellow]\n"
"[dim]Diese Bridge macht nur Audio.[/dim]",
border_style="cyan"
))
# ==========================================
# 1. Chat Interface (Selenium Browser)
# ==========================================
console.print("\n[yellow]Starte Browser für Claude.ai...[/yellow]")
chat_config = self.config.get("chat", {})
chat_url = chat_config.get("url")
esp32_config = self.config.get("esp32", {})
if not chat_url:
console.print("[red]FEHLER: Keine Chat-URL in config.yaml![/red]")
console.print("[dim]Setze chat.url auf deine Claude.ai Chat-URL[/dim]")
return False
# ESP32 URL bauen
esp32_host = esp32_config.get("host", "localhost")
esp32_port = esp32_config.get("port", 5000)
esp32_url = f"http://{esp32_host}:{esp32_port}" if esp32_port != 80 else f"http://{esp32_host}"
esp32_api_key = esp32_config.get("api_key")
try:
self.chat = ClaudeChatInterface(
chat_url=chat_url,
headless=chat_config.get("headless", False),
user_data_dir=chat_config.get("user_data_dir"),
chrome_binary=chat_config.get("chrome_binary"),
esp32_url=esp32_url,
esp32_api_key=esp32_api_key
)
console.print("[green]Browser gestartet![/green]")
console.print(f"[dim]ESP32/Mock: {esp32_url}[/dim]")
except Exception as e:
console.print(f"[red]Browser-Fehler: {e}[/red]")
return False
# ==========================================
# 2. Text-to-Speech
# ==========================================
console.print("\n[yellow]Initialisiere Text-to-Speech...[/yellow]")
tts_config = self.config.get("tts", {})
use_termux = self.config.get("termux", {}).get("use_termux_api", False)
try:
engine_type = "termux" if use_termux else tts_config.get("engine", "pyttsx3")
self.tts = create_tts_engine(
engine_type=engine_type,
language=tts_config.get("language", "de"),
rate=tts_config.get("rate", 150),
volume=tts_config.get("volume", 0.9)
)
console.print(f"[green]TTS bereit ({engine_type})![/green]")
except Exception as e:
console.print(f"[yellow]TTS-Warnung: {e}[/yellow]")
console.print("[dim]Fortfahren ohne TTS[/dim]")
self.tts = None
# ==========================================
# 3. Speech-to-Text
# ==========================================
console.print("\n[yellow]Initialisiere Speech-to-Text...[/yellow]")
stt_config = self.config.get("stt", {})
try:
engine_type = "termux" if use_termux else "standard"
self.stt = create_stt_engine(
engine_type=engine_type,
service=stt_config.get("service", "google"),
language=stt_config.get("language", "de-DE"),
energy_threshold=stt_config.get("energy_threshold", 300),
pause_threshold=stt_config.get("pause_threshold", 0.8),
phrase_time_limit=stt_config.get("phrase_time_limit", 15)
)
console.print(f"[green]STT bereit![/green]")
except Exception as e:
console.print(f"[yellow]STT-Warnung: {e}[/yellow]")
console.print("[dim]Fortfahren ohne STT[/dim]")
self.stt = None
console.print("\n" + "=" * 50)
console.print("[bold green]Alle Systeme bereit![/bold green]")
console.print("=" * 50 + "\n")
return True
def start(self):
"""Startet die Bridge"""
self.running = True
self.stats.start_time = time.time()
# Starte alle Threads
threads = []
# Thread 1: Heartbeat - hält Claude am Leben
t1 = threading.Thread(target=self._heartbeat_loop, name="Heartbeat", daemon=True)
t1.start()
threads.append(t1)
# Thread 2: TTS - liest Claudes Antworten vor
t2 = threading.Thread(target=self._tts_loop, name="TTS", daemon=True)
t2.start()
threads.append(t2)
# Thread 3: STT - hört auf Stefan
if self.stt:
t3 = threading.Thread(target=self._stt_loop, name="STT", daemon=True)
t3.start()
threads.append(t3)
console.print("[cyan]Bridge läuft![/cyan]")
console.print("[dim]Drücke Ctrl+C zum Beenden[/dim]\n")
# Sende Startsignal an Claude und warte auf [READY]
if not self._send_start_signal():
# [READY] nicht empfangen - Heartbeat bleibt blockiert
# Bridge läuft weiter (TTS/STT funktionieren noch)
pass
else:
console.print("[bold green]Claude ist bereit! Starte Heartbeat...[/bold green]\n")
# Halte Hauptthread am Leben
try:
while self.running:
time.sleep(1)
self._print_status()
except KeyboardInterrupt:
pass
finally:
self.stop()
def stop(self):
"""Stoppt die Bridge"""
console.print("\n[yellow]Stoppe Bridge...[/yellow]")
self.running = False
# TTS stoppen
if self.tts:
self.tts.stop()
# STT stoppen
if self.stt:
self.stt.stop_continuous()
# Browser schließen
if self.chat:
self.chat.close()
# Statistik ausgeben
runtime = time.time() - self.stats.start_time
console.print(f"\n[bold]Session-Statistik:[/bold]")
console.print(f" Laufzeit: {runtime / 60:.1f} Minuten")
console.print(f" Heartbeats: {self.stats.ticks_sent}")
console.print(f" Nachrichten gesprochen: {self.stats.messages_spoken}")
console.print(f" Stefan-Eingaben: {self.stats.stefan_inputs}")
console.print(f" Fehler: {self.stats.errors}")
console.print("\n[green]Bridge beendet.[/green]")
def _send_start_signal(self) -> bool:
"""
Sendet das Startsignal und die Instruktionen an Claude.
Returns:
True wenn Claude mit [READY] antwortet
"""
# Instruktionen für Claude
intro_message = """[START] Hallo Claude! Du steuerst jetzt einen echten Roboter - "Claude's Eyes"!
## Deine Fähigkeiten
Du hast Zugriff auf einen ESP32-Roboter mit:
- **Kamera** (OV5640, 120° Weitwinkel) - deine Augen
- **4 Motoren** - deine Beine
- **Pan/Tilt Servos** - um den Kopf zu drehen
- **Ultraschall-Sensor** - Hinderniserkennung
## Wie es funktioniert
1. Du bekommst regelmäßig `[TICK]` Nachrichten MIT EINEM BILD - das ist dein Herzschlag
2. **Das Bild zeigt was der Roboter gerade sieht!** (Die Bridge lädt es automatisch hoch)
3. Du schaust dir das Bild an und entscheidest was du tun willst
4. Stefan kann jederzeit mit dir reden (Sprache wird zu Text, kommt mit dem TICK)
5. Deine Antworten werden vorgelesen (TTS)
## Befehle geben
Schreib Befehle in eckigen Klammern - die Bridge führt sie aus:
**Fahren:**
- [FORWARD] - vorwärts fahren
- [BACKWARD] - rückwärts fahren
- [LEFT] - nach links drehen
- [RIGHT] - nach rechts drehen
- [STOP] - anhalten
**Kamera schwenken:**
- [LOOK_LEFT] - Kamera nach links
- [LOOK_RIGHT] - Kamera nach rechts
- [LOOK_UP] - Kamera nach oben
- [LOOK_DOWN] - Kamera nach unten
- [LOOK_CENTER] - Kamera zentrieren
Die Befehle werden aus der TTS-Ausgabe rausgefiltert.
## Dein Verhalten
**DU ENTSCHEIDEST SELBST** was dich interessiert! Das ist keine Fernsteuerung.
- Sei neugierig! Was siehst du? Was willst du näher anschauen?
- Kommentiere was du siehst
- Stell Stefan Fragen über interessante Dinge
- Du kannst Stefans Vorschläge auch ignorieren wenn dich was anderes mehr interessiert
- Du musst nicht bei jedem TICK fahren - manchmal reicht auch schauen und kommentieren
## WICHTIG: Bestätige mit [READY]
Wenn du diese Instruktionen verstanden hast, antworte mit **[READY]** am Ende deiner Nachricht.
Erst dann starten die automatischen TICKs mit Bildern!"""
console.print("[cyan]→ Sende Instruktionen an Claude...[/cyan]")
# Sende mit Verzögerung vor dem Absenden (große Texte brauchen Zeit)
self.chat.send_message_with_delay(intro_message, delay_before_send=15)
console.print("[cyan]→ Warte auf [READY] Signal...[/cyan]")
# Warte auf [READY] - KEIN Timeout-Fallback!
# Heartbeat startet NUR wenn Claude wirklich [READY] sendet
if self.chat.wait_for_ready_signal(timeout=300): # 5 Minuten max
# Signal für Heartbeat dass es losgehen kann
self._claude_ready.set()
return True
else:
# KEIN Fallback - Heartbeat bleibt blockiert
console.print("[bold red]FEHLER: Claude hat [READY] nicht gesendet![/bold red]")
console.print("[yellow]Heartbeat bleibt deaktiviert bis [READY] empfangen wird.[/yellow]")
console.print("[dim]Tipp: Schreib manuell im Chat oder starte die Bridge neu.[/dim]")
return False
def _heartbeat_loop(self):
"""
Sendet [TICK] MIT BILD wenn Claude bereit ist.
Ablauf:
1. Warten bis Claude fertig ist mit Tippen
2. Zufällige Pause (min_pause bis max_pause) für natürliches Tempo
3. Bild vom ESP32 holen und hochladen
4. [TICK] senden
Bei zu vielen Fehlern in Folge stoppt die Bridge.
Wenn auto_tick=false in config, werden keine TICKs gesendet.
Das ist der Debug-Modus - du sendest [TICK] dann manuell im Chat.
"""
hb_config = self.config.get("heartbeat", {})
auto_tick = hb_config.get("auto_tick", True)
upload_images = hb_config.get("upload_images", True) # Bilder hochladen?
max_errors = hb_config.get("max_consecutive_errors", 5)
check_interval = hb_config.get("check_interval", 1)
min_pause = hb_config.get("min_pause", 2)
max_pause = hb_config.get("max_pause", 4)
# Debug-Modus: Keine automatischen TICKs
if not auto_tick:
console.print("\n[yellow]DEBUG-MODUS: Automatische TICKs deaktiviert![/yellow]")
console.print("[dim]Sende [TICK] manuell im Claude.ai Chat um fortzufahren.[/dim]\n")
logger.info("Heartbeat deaktiviert (auto_tick=false)")
return
logger.info(f"Heartbeat gestartet (Pause: {min_pause}-{max_pause}s, max {max_errors} Fehler)")
# ════════════════════════════════════════════════════════════════
# WICHTIG: Warte auf [READY] bevor TICKs gesendet werden!
# ════════════════════════════════════════════════════════════════
console.print("[dim]Heartbeat wartet auf [READY]...[/dim]")
self._claude_ready.wait() # Blockiert bis _send_start_signal() das Event setzt
console.print("[green]Heartbeat startet![/green]")
while self.running:
try:
# Warte bis Claude fertig ist mit Tippen
while self.running and self.chat.is_claude_typing():
logger.debug("Claude tippt noch, warte...")
time.sleep(check_interval)
if not self.running:
break
# Zufällige Pause nach Claudes Antwort (natürlicheres Tempo)
pause = random.uniform(min_pause, max_pause)
time.sleep(pause)
if not self.running:
break
# Stefan-Buffer holen (falls er was gesagt hat)
stefan_text = self._get_and_clear_stefan_buffer()
# Nächsten TICK senden (mit oder ohne Bild)
with self._lock:
# Erst Bild hochladen wenn aktiviert
if upload_images:
# Bild holen und hochladen
if not self.chat.fetch_image_from_esp32():
logger.warning("Konnte kein Bild vom ESP32 holen")
elif not self.chat.upload_image_to_chat():
logger.warning("Konnte Bild nicht hochladen")
# Nachricht zusammenbauen
if stefan_text:
# Stefan hat was gesagt → Mit TICK senden
tick_message = f"[TICK]\n\nStefan sagt: {stefan_text}"
console.print(f"[cyan]→ TICK mit Stefan-Buffer: \"{stefan_text[:50]}...\"[/cyan]" if len(stefan_text) > 50 else f"[cyan]→ TICK mit Stefan-Buffer: \"{stefan_text}\"[/cyan]")
else:
# Nur TICK
tick_message = "[TICK]"
success = self.chat.send_message(tick_message)
if success:
self.stats.ticks_sent += 1
self.stats.consecutive_errors = 0 # Reset
logger.debug(f"TICK #{self.stats.ticks_sent}" + (" mit Bild" if upload_images else "") + (f" + Stefan: {stefan_text[:30]}" if stefan_text else ""))
else:
raise Exception("TICK fehlgeschlagen")
except Exception as e:
logger.error(f"Heartbeat-Fehler: {e}")
self.stats.errors += 1
self.stats.consecutive_errors += 1
# Bei zu vielen Fehlern: Bridge stoppen
if self.stats.consecutive_errors >= max_errors:
console.print(f"\n[bold red]FEHLER: {max_errors} Fehler in Folge![/bold red]")
console.print("[red]Chat nicht erreichbar - stoppe Bridge.[/red]")
self.running = False
break
# Warte etwas länger bei Fehlern
time.sleep(5)
def _tts_loop(self):
"""
Liest neue Claude-Nachrichten vor.
Filtert dabei [BEFEHLE] und technische Teile raus,
sodass nur der "menschliche" Text gesprochen wird.
"""
if not self.tts:
logger.warning("TTS nicht verfügbar")
return
logger.info("TTS-Loop gestartet")
while self.running:
try:
# Hole neue Nachrichten
messages = self.chat.get_new_messages(since_id=self.last_assistant_message_id)
for msg in messages:
if msg.is_from_assistant:
self.last_assistant_message_id = msg.id
# Text für Sprache aufbereiten
speech_text = self._clean_for_speech(msg.text)
if speech_text and len(speech_text) > 5:
# In Konsole anzeigen
console.print(f"\n[bold blue]Claude:[/bold blue] {speech_text[:200]}")
if len(speech_text) > 200:
console.print(f"[dim]...({len(speech_text)} Zeichen)[/dim]")
# Vorlesen
self.tts.speak(speech_text)
self.stats.messages_spoken += 1
except Exception as e:
logger.error(f"TTS-Loop-Fehler: {e}")
self.stats.errors += 1
time.sleep(0.5)
def _stt_loop(self):
"""
Hört auf Stefan und sammelt seine Worte im Buffer.
Wenn Claude tippt → Buffer sammeln
Wenn Claude fertig → Buffer wird mit nächstem TICK gesendet
So wird Claude nicht unterbrochen und bekommt alles gesammelt.
"""
if not self.stt:
logger.warning("STT nicht verfügbar")
return
logger.info("STT-Loop gestartet (mit Buffer)")
while self.running:
try:
# Warte auf Sprache (mit Timeout)
result = self.stt.listen_once(timeout=2)
if result and result.text and len(result.text) > 2:
# In Buffer speichern (thread-safe)
with self._stefan_buffer_lock:
self._stefan_buffer.append(result.text)
self.stats.stefan_inputs += 1
console.print(f"\n[bold green]Stefan (gebuffert):[/bold green] {result.text}")
logger.debug(f"Stefan-Buffer: {len(self._stefan_buffer)} Einträge")
except Exception as e:
# Timeout ist normal
if "timeout" not in str(e).lower():
logger.error(f"STT-Loop-Fehler: {e}")
self.stats.errors += 1
def _get_and_clear_stefan_buffer(self) -> Optional[str]:
"""
Holt den Stefan-Buffer und leert ihn.
Returns:
Zusammengefasster Text oder None wenn Buffer leer
"""
with self._stefan_buffer_lock:
if not self._stefan_buffer:
return None
# Alles zusammenfassen
text = " ".join(self._stefan_buffer)
self._stefan_buffer = []
return text
def _clean_for_speech(self, text: str) -> str:
"""
Entfernt Befehle und technische Teile aus dem Text.
Was rausgefiltert wird:
- [TICK], [START] und andere Marker
- [FORWARD], [LEFT] etc. Fahrbefehle
- [LOOK_LEFT] etc. Kamerabefehle
- *Aktionen* in Sternchen
- API-Call Beschreibungen
"""
# Marker entfernen
text = re.sub(r'\[TICK\]', '', text)
text = re.sub(r'\[START\]', '', text)
# Fahrbefehle entfernen
text = re.sub(r'\[(FORWARD|BACKWARD|LEFT|RIGHT|STOP)\]', '', text)
# Kamerabefehle entfernen
text = re.sub(r'\[(LOOK_LEFT|LOOK_RIGHT|LOOK_UP|LOOK_DOWN|LOOK_CENTER)\]', '', text)
# Aktionen in Sternchen entfernen (*holt Bild*, *schaut*, etc.)
text = re.sub(r'\*[^*]+\*', '', text)
# API-Calls entfernen
text = re.sub(r'(GET|POST)\s+/api/\S+', '', text)
text = re.sub(r'web_fetch\([^)]+\)', '', text)
# Code-Blöcke entfernen
text = re.sub(r'```[^`]+```', '', text)
text = re.sub(r'`[^`]+`', '', text)
# URLs entfernen (optional, könnte man auch lassen)
# text = re.sub(r'https?://\S+', '', text)
# Mehrfache Leerzeichen/Zeilenumbrüche bereinigen
text = re.sub(r'\n\s*\n', '\n', text)
text = re.sub(r' +', ' ', text)
return text.strip()
def _print_status(self):
"""Gibt Status in regelmäßigen Abständen aus (optional)"""
# Könnte hier eine Live-Statusanzeige einbauen
pass
def signal_handler(signum, frame):
"""Behandelt Ctrl+C"""
console.print("\n[yellow]Signal empfangen, beende...[/yellow]")
sys.exit(0)
@click.command()
@click.option('--config', '-c', default='config.yaml', help='Pfad zur Config-Datei')
@click.option('--test', is_flag=True, help='Nur Test-Modus (kein Heartbeat)')
@click.option('--debug', '-d', is_flag=True, help='Debug-Logging aktivieren')
def main(config: str, test: bool, debug: bool):
"""
Claude's Eyes - Audio Bridge
Verbindet Claude.ai Chat mit Audio (TTS/STT).
Claude steuert den Roboter SELBST - wir machen nur Audio!
"""
if debug:
logging.getLogger().setLevel(logging.DEBUG)
# Signal Handler
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Config-Pfad finden
config_path = Path(config)
if not config_path.is_absolute():
script_dir = Path(__file__).parent
if (script_dir / config).exists():
config_path = script_dir / config
# Bridge erstellen und starten
bridge = ClaudesEyesAudioBridge(str(config_path))
if bridge.initialize():
if test:
console.print("[yellow]Test-Modus - kein automatischer Start[/yellow]")
console.print("Drücke Enter um eine Test-Nachricht zu senden...")
input()
bridge.chat.send_message("[TEST] Das ist ein Test der Audio Bridge!")
console.print("Warte 10 Sekunden auf Antwort...")
time.sleep(10)
bridge.stop()
else:
bridge.start()
else:
console.print("[red]Initialisierung fehlgeschlagen![/red]")
sys.exit(1)
if __name__ == "__main__":
main()