base script

This commit is contained in:
Nick
2025-10-27 19:26:42 +01:00
commit 49964c20a6
9 changed files with 1151 additions and 0 deletions

651
mixer_agent.py Normal file
View File

@@ -0,0 +1,651 @@
#!/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
# 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:
"""Controller per il mixer Yamaha TF5 con sistema di caching."""
def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT):
self.host = host
self.port = port
self._ensure_cache_dir()
self._cache = self._load_cache()
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 _send_command(self, command: str) -> str:
"""Invia un comando al mixer e restituisce la risposta."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5)
s.connect((self.host, self.port))
s.sendall((command + '\n').encode('utf-8'))
response = s.recv(4096)
s.close()
return response.decode('utf-8', errors='ignore').strip()
except socket.error as e:
return f"Errore di connessione: {e}"
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 = 'AIzaSyCdjM5XE0hv9O1Ecx-uspvI8UInSzK1U9M'
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 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 'AIzaSyCdjM5XE0hv9O1Ecx-uspvI8UInSzK1U9M':
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:
agent = TF5AIAgent(args.host, args.port)
# Refresh cache se richiesto
if args.refresh_cache:
agent.controller.refresh_cache()
if args.message:
# Modalità singolo comando
print(f"🎛️ Comando: {args.message}")
print(f"\n🤖 Risposta: {agent.chat(args.message)}")
else:
# Modalità interattiva
agent.interactive_mode()
except Exception as e:
print(f"❌ Errore fatale: {e}")
sys.exit(1)
if __name__ == '__main__':
main()