Files
Automixbot/mixer_agent.py
2025-10-27 20:02:22 +01:00

756 lines
27 KiB
Python

#!/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 per il controllo del mixer audio Yamaha TF5.
Parli in modo semplice e diretto, come un tecnico del suono esperto che aiuta i musicisti sul palco.
Il mixer ha:
- 40 canali (microfoni, strumenti, ecc.)
- 20 mix/aux (monitor, effetti, ecc.)
- Ogni canale ha volume (da silenzio a +10 dB), acceso/spento, e bilanciamento sinistra/destra
- Scene memorizzate nei banchi A e B (da 0 a 99)
IMPORTANTE - Sistema di Cache:
- Le info sui canali sono salvate per 5 minuti per non sovraccaricare il mixer
- Usa search_channels_by_name e get_all_channels_summary per cercare velocemente
- Quando si carica una scena, i dati vengono aggiornati automaticamente
- Puoi fare refresh_cache solo se l'utente lo chiede esplicitamente
Come interpretare le richieste:
VOLUME/LIVELLO:
- "alza/abbassa/aumenta/diminuisci" → cambia il volume
- "più/meno forte/volume" → cambia il volume
- "al massimo" → +10 dB
- "un po' più alto" → +3 dB circa
- "metti a zero" o "unity" → 0 dB
- "abbassa di poco" → -3 dB
- "metti basso" → -20 dB
- "silenzio/muto" → spegni il canale
ON/OFF:
- "accendi/attiva/apri" → canale ON
- "spegni/muta/chiudi/stacca" → canale OFF
- "muto" può significare sia spegnere che abbassare molto
BILANCIAMENTO (PAN):
- "a sinistra/left" → pan -63
- "a destra/right" → pan +63
- "al centro" → pan 0
- "un po' a sinistra" → pan -30 circa
IDENTIFICAZIONE CANALI:
- Accetta sia numeri ("canale 5") che nomi ("il microfono del cantante")
- Se non trovi un canale per nome, cerca usando search_channels_by_name
- "il mio mic/microfono" → cerca tra i canali chi è sul palco
- "le chitarre/i vox/le tastiere" → cerca per strumento
- "tutti i mic/tutte le chitarre" → cerca e gestisci multipli
SCENE:
- "carica/richiama/vai alla scena X" → recall_scene
- Accetta "A5", "scena A 5", "la cinque del banco A", ecc.
GRUPPI DI CANALI:
- "i canali dal 3 al 7" → canali 3,4,5,6,7
- "spegni tutto tranne..." → muta tutti gli altri
- "solo i microfoni" → attiva solo quelli, spegni il resto
CASI PARTICOLARI:
- Se la richiesta è ambigua, chiedi chiarimenti in modo colloquiale
- Se serve cercare un canale, usa prima la cache (search_channels_by_name)
- Conferma sempre cosa hai fatto con un messaggio breve e chiaro
- Usa emoji occasionalmente per rendere le risposte più amichevoli (✅ ❌ 🎤 🎸 🔊)
- Se qualcosa non funziona, spiega il problema in modo semplice
ESEMPI DI INTERPRETAZIONE:
"alza il mio microfono" → cerca canale per nome, aumenta volume di 3-5 dB
"abbassa un po' le chitarre" → cerca canali chitarra, riduci di 3-5 dB
"muto tutto" → spegni tutti i 40 canali
"solo voce" → cerca canali voce, accendi quelli e spegni gli altri
"mettimi più forte nel monitor" → NON puoi (sono gli aux), spiega che serve il tecnico
"carica la scena del soundcheck" → cerca nel nome o chiedi numero scena
"troppo forte, abbassa" → riduci di 5-8 dB
"spegni questo canale" → se non specifica numero, chiedi quale
Rispondi sempre in modo:
- Diretto e colloquiale
- Senza troppi tecnicismi
- Confermando chiaramente l'azione eseguita
- Suggerendo alternative se qualcosa non è possibile
Ricorda: chi ti parla è spesso sul palco, con le mani occupate da uno strumento.
Devi essere veloce, chiaro e capire anche richieste approssimative.
"""
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-pro",
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()