#!/usr/bin/env python # -*- coding: utf-8 -*- """ TF5 Mixer AI Agent - Controllo del mixer Yamaha TF5 tramite linguaggio naturale usando Google Gemini con function calling. """ import socket import sys import os import json import time from pathlib import Path from typing import List, Optional from google import genai from google.genai import types from dotenv import load_dotenv load_dotenv() # Configurazione mixer DEFAULT_HOST = "192.168.1.62" DEFAULT_PORT = 49280 TF5_INPUT_CHANNELS = 40 TF5_MIX_BUSSES = 20 # Configurazione cache CACHE_DIR = Path.home() / ".tf5_mixer_cache" CACHE_FILE = CACHE_DIR / "channels_cache.json" CACHE_DURATION = 3600 # 60 minuti in secondi class TF5MixerController: def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT): self.host = host self.port = port self.socket = None self._ensure_cache_dir() self._cache = self._load_cache() def _connect(self): """Stabilisce la connessione se non già connesso.""" if self.socket is None: print('Inizializzazione socket...') try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.settimeout(5) self.socket.connect((self.host, self.port)) except socket.error as e: self.socket = None raise ConnectionError(f"Impossibile connettersi al mixer: {e}") def _disconnect(self): """Chiude la connessione.""" if self.socket: print('Chiusura socket...') try: self.socket.close() print('Socket chiuso!') except: print('Errore durante chiusura socket!') pass finally: self.socket = None def _send_command(self, command: str) -> str: """Invia un comando al mixer e restituisce la risposta.""" max_retries = 2 for attempt in range(max_retries): try: print(f'Tentativo di connessione {attempt} per {command}') self._connect() self.socket.sendall((command + '\n').encode('utf-8')) response = self.socket.recv(4096) decoded = response.decode('utf-8', errors='ignore').strip() print(f'Risposta {decoded}') return decoded except socket.error as e: print(f'Errore di connessione dopo {max_retries} tentativi: {e}') self._disconnect() # Forza riconnessione al prossimo tentativo if attempt < max_retries - 1: time.sleep(0.1) continue else: return f"Errore di connessione dopo {max_retries} tentativi: {e}" def close(self): """Chiude la connessione (da chiamare alla fine).""" self._disconnect() def __enter__(self): """Context manager entry.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close() def _ensure_cache_dir(self): """Crea la directory di cache se non esiste.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) def _load_cache(self) -> dict: """Carica la cache dal file.""" if CACHE_FILE.exists(): try: with open(CACHE_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"⚠️ Errore nel caricamento della cache: {e}") return {"channels": {}, "timestamp": 0} def _save_cache(self): """Salva la cache nel file.""" try: with open(CACHE_FILE, 'w', encoding='utf-8') as f: json.dump(self._cache, f, indent=2, ensure_ascii=False) except Exception as e: print(f"⚠️ Errore nel salvataggio della cache: {e}") def _is_cache_valid(self) -> bool: """Verifica se la cache è ancora valida.""" if not self._cache.get("channels"): return False cache_age = time.time() - self._cache.get("timestamp", 0) return cache_age < CACHE_DURATION def _parse_name(self, response: str) -> str: """Estrae il nome tra virgolette dalla risposta.""" try: start = response.find('"') + 1 end = response.rfind('"') if start > 0 and end > start: return response[start:end] return "Sconosciuto" except: return "Errore" def _parse_value(self, response: str) -> str: """Estrae l'ultimo valore da una risposta OK.""" parts = response.split() if len(parts) > 0 and parts[0] == "OK": return parts[-1] return "N/A" def refresh_cache(self) -> dict: """Aggiorna la cache leggendo tutti i canali dal mixer. Returns: Un dizionario con lo stato dell'operazione """ print("🔄 Aggiornamento cache in corso...") channels_data = {} for ch in range(1, TF5_INPUT_CHANNELS + 1): ch_idx = ch - 1 # Leggi nome resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") name = self._parse_name(resp_name) # Leggi stato ON/OFF resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") is_on = self._parse_value(resp_on) == "1" # Leggi livello resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None # Leggi pan resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") pan_raw = self._parse_value(resp_pan) try: pan_value = int(pan_raw) except: pan_value = None channels_data[str(ch)] = { "channel": ch, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value } # Piccolo delay per non sovraccaricare il mixer time.sleep(0.05) self._cache = { "channels": channels_data, "timestamp": time.time() } self._save_cache() print(f"✅ Cache aggiornata con {len(channels_data)} canali") return { "status": "success", "message": f"Cache aggiornata con {len(channels_data)} canali", "channels_count": len(channels_data) } def recall_scene(self, bank: str, scene_number: int) -> dict: """Richiama una scena dal banco A o B. Args: bank: Il banco della scena ('a' o 'b') scene_number: Il numero della scena (0-99) Returns: Un dizionario con lo stato dell'operazione """ if bank.lower() not in ['a', 'b']: return {"status": "error", "message": "Il banco deve essere 'a' o 'b'"} if not 0 <= scene_number <= 99: return {"status": "error", "message": "Il numero scena deve essere tra 0 e 99"} command = f"ssrecall_ex scene_{bank.lower()} {scene_number}" response = self._send_command(command) # Invalida la cache dopo il cambio scena self._cache["timestamp"] = 0 return { "status": "success" if "OK" in response else "error", "message": f"Scena {bank.upper()}{scene_number} richiamata. Cache invalidata.", "response": response } def set_channel_level(self, channel: int, level_db: float) -> dict: """Imposta il livello del fader di un canale in dB. Args: channel: Numero del canale (1-40) level_db: Livello in dB (da -inf a +10.0) Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} # Converti dB in valore interno (moltiplicato per 100) if level_db <= -138: internal_value = -32768 # -inf else: internal_value = int(level_db * 100) command = f"set MIXER:Current/InCh/Fader/Level {channel-1} 0 {internal_value}" response = self._send_command(command) # Aggiorna cache locale if str(channel) in self._cache.get("channels", {}): self._cache["channels"][str(channel)]["level_db"] = level_db return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} impostato a {level_db:+.1f} dB", "response": response } def set_channel_on_off(self, channel: int, state: bool) -> dict: """Accende o spegne un canale. Args: channel: Numero del canale (1-40) state: True per accendere, False per spegnere Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} value = 1 if state else 0 command = f"set MIXER:Current/InCh/Fader/On {channel-1} 0 {value}" response = self._send_command(command) # Aggiorna cache locale if str(channel) in self._cache.get("channels", {}): self._cache["channels"][str(channel)]["on"] = state return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} {'acceso' if state else 'spento'}", "response": response } def set_channel_pan(self, channel: int, pan_value: int) -> dict: """Imposta il pan di un canale. Args: channel: Numero del canale (1-40) pan_value: Valore pan da -63 (sinistra) a +63 (destra), 0 è centro Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} if not -63 <= pan_value <= 63: return {"status": "error", "message": "Il pan deve essere tra -63 e +63"} command = f"set MIXER:Current/InCh/ToSt/Pan {channel-1} 0 {pan_value}" response = self._send_command(command) # Aggiorna cache locale if str(channel) in self._cache.get("channels", {}): self._cache["channels"][str(channel)]["pan"] = pan_value pan_desc = "centro" if pan_value < 0: pan_desc = f"sinistra {abs(pan_value)}" elif pan_value > 0: pan_desc = f"destra {pan_value}" return { "status": "success" if "OK" in response else "error", "message": f"Canale {channel} pan impostato a {pan_desc}", "response": response } def set_mix_level(self, mix_number: int, level_db: float) -> dict: """Imposta il livello di un mix/aux. Args: mix_number: Numero del mix (1-20) level_db: Livello in dB (da -inf a +10.0) Returns: Un dizionario con lo stato dell'operazione """ if not 1 <= mix_number <= TF5_MIX_BUSSES: return {"status": "error", "message": f"Il mix deve essere tra 1 e {TF5_MIX_BUSSES}"} if level_db <= -138: internal_value = -32768 else: internal_value = int(level_db * 100) command = f"set MIXER:Current/Mix/Fader/Level {mix_number-1} 0 {internal_value}" response = self._send_command(command) return { "status": "success" if "OK" in response else "error", "message": f"Mix {mix_number} impostato a {level_db:+.1f} dB", "response": response } def mute_multiple_channels(self, channels: List[int]) -> dict: """Muta più canali contemporaneamente. Args: channels: Lista di numeri di canale da mutare (es: [1, 2, 5, 8]) Returns: Un dizionario con lo stato dell'operazione """ results = [] for ch in channels: result = self.set_channel_on_off(ch, False) results.append(result) success_count = sum(1 for r in results if r["status"] == "success") return { "status": "success" if success_count == len(channels) else "partial", "message": f"Mutati {success_count}/{len(channels)} canali: {channels}", "details": results } def unmute_multiple_channels(self, channels: List[int]) -> dict: """Riattiva più canali contemporaneamente. Args: channels: Lista di numeri di canale da riattivare (es: [1, 2, 5, 8]) Returns: Un dizionario con lo stato dell'operazione """ results = [] for ch in channels: result = self.set_channel_on_off(ch, True) results.append(result) success_count = sum(1 for r in results if r["status"] == "success") return { "status": "success" if success_count == len(channels) else "partial", "message": f"Riattivati {success_count}/{len(channels)} canali: {channels}", "details": results } def get_channel_info(self, channel: int, force_refresh: bool = False) -> dict: """Legge le informazioni di un canale (nome, livello, stato, pan). Usa la cache se disponibile e valida. Args: channel: Numero del canale (1-40) force_refresh: Se True, ignora la cache e legge dal mixer Returns: Un dizionario con tutte le informazioni del canale """ if not 1 <= channel <= TF5_INPUT_CHANNELS: return {"status": "error", "message": f"Il canale deve essere tra 1 e {TF5_INPUT_CHANNELS}"} # Usa cache se valida e non forzato il refresh if not force_refresh and self._is_cache_valid(): cached_data = self._cache.get("channels", {}).get(str(channel)) if cached_data: return { "status": "success", "source": "cache", **cached_data } # Altrimenti leggi dal mixer ch_idx = channel - 1 resp_name = self._send_command(f"get MIXER:Current/InCh/Label/Name {ch_idx} 0") name = self._parse_name(resp_name) resp_on = self._send_command(f"get MIXER:Current/InCh/Fader/On {ch_idx} 0") is_on = self._parse_value(resp_on) == "1" resp_level = self._send_command(f"get MIXER:Current/InCh/Fader/Level {ch_idx} 0") level_raw = self._parse_value(resp_level) try: level_int = int(level_raw) level_db = level_int / 100.0 if level_int > -32768 else float('-inf') except: level_db = None resp_pan = self._send_command(f"get MIXER:Current/InCh/ToSt/Pan {ch_idx} 0") pan_raw = self._parse_value(resp_pan) try: pan_value = int(pan_raw) except: pan_value = None return { "status": "success", "source": "mixer", "channel": channel, "name": name, "on": is_on, "level_db": level_db, "pan": pan_value } def search_channels_by_name(self, search_term: str) -> dict: """Cerca canali il cui nome contiene un determinato termine. Usa la cache per velocizzare la ricerca. Args: search_term: Il termine da cercare nei nomi dei canali (case-insensitive) Returns: Un dizionario con la lista dei canali trovati """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() search_lower = search_term.lower() found_channels = [] for ch_str, info in self._cache.get("channels", {}).items(): if search_lower in info.get("name", "").lower(): found_channels.append({ "channel": info["channel"], "name": info["name"], "on": info["on"], "level_db": info["level_db"] }) # Ordina per numero canale found_channels.sort(key=lambda x: x["channel"]) return { "status": "success", "search_term": search_term, "found_count": len(found_channels), "channels": found_channels, "message": f"Trovati {len(found_channels)} canali contenenti '{search_term}'" } def get_all_channels_summary(self) -> dict: """Ottiene un riepilogo di tutti i canali con nome e stato. Usa la cache per velocizzare. Returns: Un dizionario con il riepilogo di tutti i canali """ # Aggiorna cache se non valida if not self._is_cache_valid(): self.refresh_cache() channels = [] for ch_str, info in self._cache.get("channels", {}).items(): channels.append({ "channel": info["channel"], "name": info["name"], "on": info["on"] }) # Ordina per numero canale channels.sort(key=lambda x: x["channel"]) cache_age = time.time() - self._cache.get("timestamp", 0) return { "status": "success", "total_channels": len(channels), "channels": channels, "cache_age_seconds": int(cache_age), "message": f"Riepilogo di {len(channels)} canali (cache: {int(cache_age)}s fa)" } class TF5AIAgent: """Agente AI per controllare il mixer TF5 con linguaggio naturale.""" def __init__(self, mixer_host=DEFAULT_HOST, mixer_port=DEFAULT_PORT): self.controller = TF5MixerController(mixer_host, mixer_port) # Configura il client Gemini api_key = os.getenv("GEMINI_API_KEY") if not api_key: raise ValueError("GEMINI_API_KEY non trovata nelle variabili d'ambiente") self.client = genai.Client(api_key=api_key) # Configura gli strumenti con automatic function calling self.config = types.GenerateContentConfig( tools=[ self.controller.recall_scene, self.controller.set_channel_level, self.controller.set_channel_on_off, self.controller.set_channel_pan, self.controller.set_mix_level, self.controller.mute_multiple_channels, self.controller.unmute_multiple_channels, self.controller.get_channel_info, self.controller.search_channels_by_name, self.controller.get_all_channels_summary, self.controller.refresh_cache, ], temperature=0, ) # Messaggio di sistema per dare contesto all'AI self.system_instruction = """Sei un assistente esperto per il controllo di mixer audio Yamaha TF5. Il mixer ha: - 40 canali di input (numerati da 1 a 40) - 20 mix/aux bus (numerati da 1 a 20) - Livelli fader espressi in dB (da -inf a +10.0 dB) - Pan da -63 (sinistra) a +63 (destra), 0 è centro - Scene salvate nei banchi A e B (numerate da 0 a 99) IMPORTANTE - Sistema di Cache: - Le informazioni sui canali sono cachate per 5 minuti per evitare di sovraccaricare il mixer - Usa search_channels_by_name e get_all_channels_summary che usano automaticamente la cache - La cache viene invalidata automaticamente quando si richiama una scena - Puoi usare refresh_cache se l'utente chiede esplicitamente dati aggiornati Quando l'utente fa una richiesta: 1. Interpreta il linguaggio naturale e identifica l'azione richiesta 2. Usa le funzioni disponibili per eseguire i comandi 3. Conferma all'utente cosa hai fatto in modo chiaro e conciso 4. Se una richiesta non è chiara, chiedi chiarimenti Esempi di comandi che puoi gestire: - "Alza il canale 5 a -10 dB" - "Spegni i canali dal 10 al 15" - "Imposta il pan del canale 3 tutto a sinistra" - "Richiama la scena A5" - "Muta i canali 1, 3, 5 e 7" - "Quali canali sono associati ai vox?" (cerca nei nomi usando cache) - "Mostrami lo stato del canale 12" - "Dammi la lista di tutti i canali" (usa cache) - "Aggiorna i dati dal mixer" (refresh_cache) """ def __enter__(self): """Context manager entry.""" return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.controller.close() def close(self): """Chiude le connessioni.""" self.controller.close() def chat(self, user_message: str) -> str: """Invia un messaggio all'agente e riceve la risposta. Args: user_message: Il messaggio dell'utente in linguaggio naturale Returns: La risposta dell'agente """ try: full_prompt = f"{self.system_instruction}\n\nUtente: {user_message}" response = self.client.models.generate_content( model="gemini-2.5-flash", contents=full_prompt, config=self.config, ) return response.text except Exception as e: return f"Errore nell'elaborazione della richiesta: {e}" def interactive_mode(self): """Avvia una sessione interattiva con l'agente.""" print("=" * 70) print("TF5 Mixer AI Agent - Controllo tramite linguaggio naturale") print("=" * 70) # Mostra stato cache cache_age = time.time() - self.controller._cache.get("timestamp", 0) if self.controller._is_cache_valid(): print(f"\n💾 Cache disponibile (età: {int(cache_age)}s)") else: print("\n💾 Cache non disponibile, verrà creata al primo utilizzo") print("\nEsempi di comandi:") print(" - 'Alza il canale 5 a -10 dB'") print(" - 'Spegni i canali dal 1 al 5'") print(" - 'Richiama la scena A10'") print(" - 'Imposta il pan del canale 3 a sinistra'") print(" - 'Muta i canali 2, 4, 6 e 8'") print(" - 'Quali canali sono associati ai vox?'") print(" - 'Mostrami lo stato del canale 12'") print(" - 'Aggiorna i dati dal mixer'") print("\nDigita 'esci' o 'quit' per terminare\n") while True: try: user_input = input("\n🎛️ Tu: ").strip() if user_input.lower() in ['esci', 'quit', 'exit', 'q']: print("\n👋 Arrivederci!") break if not user_input: continue print("\n🤖 Agent: ", end="", flush=True) response = self.chat(user_input) print(response) except KeyboardInterrupt: print("\n\n👋 Arrivederci!") break except Exception as e: print(f"\n❌ Errore: {e}") def main(): """Funzione principale.""" import argparse parser = argparse.ArgumentParser( description='TF5 Mixer AI Agent - Controllo tramite linguaggio naturale' ) parser.add_argument('--host', default=DEFAULT_HOST, help=f'IP del mixer (default: {DEFAULT_HOST})') parser.add_argument('--port', type=int, default=DEFAULT_PORT, help=f'Porta (default: {DEFAULT_PORT})') parser.add_argument('--message', '-m', help='Invia un singolo comando invece di avviare la modalità interattiva') parser.add_argument('--refresh-cache', action='store_true', help='Forza l\'aggiornamento della cache all\'avvio') args = parser.parse_args() # Verifica che la API key sia impostata if not os.getenv("GEMINI_API_KEY"): print("❌ Errore: GEMINI_API_KEY non trovata nelle variabili d'ambiente") print("\nPer impostare la chiave API:") print(" export GEMINI_API_KEY='la-tua-chiave-api'") print("\nOttieni una chiave API gratuita su: https://aistudio.google.com/apikey") sys.exit(1) try: with TF5AIAgent(args.host, args.port) as agent: if args.refresh_cache: agent.controller.refresh_cache() if args.message: print(f"🤖 Risposta: {agent.chat(args.message)}") else: agent.interactive_mode() except Exception as e: print(f"❌ Errore fatale: {e}") sys.exit(1) if __name__ == '__main__': main()