web console
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
"channel": 2,
|
||||
"name": "Gelato",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": -8.4,
|
||||
"pan": 0
|
||||
},
|
||||
"3": {
|
||||
@@ -31,15 +31,15 @@
|
||||
"5": {
|
||||
"channel": 5,
|
||||
"name": "Vox1",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": 0.8,
|
||||
"pan": 0
|
||||
},
|
||||
"6": {
|
||||
"channel": 6,
|
||||
"name": "Vox2",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": 2.75,
|
||||
"pan": 0
|
||||
},
|
||||
"7": {
|
||||
@@ -59,50 +59,50 @@
|
||||
"9": {
|
||||
"channel": 9,
|
||||
"name": "Kick",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": -1.25,
|
||||
"pan": 0
|
||||
},
|
||||
"10": {
|
||||
"channel": 10,
|
||||
"name": "Snare",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": -2.35,
|
||||
"pan": 0
|
||||
},
|
||||
"11": {
|
||||
"channel": 11,
|
||||
"name": "Tom 1",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": -3.55,
|
||||
"pan": 0
|
||||
},
|
||||
"12": {
|
||||
"channel": 12,
|
||||
"name": "Tom 2",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": -5.9,
|
||||
"pan": 0
|
||||
},
|
||||
"13": {
|
||||
"channel": 13,
|
||||
"name": "Tom3",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": -8.65,
|
||||
"pan": 0
|
||||
},
|
||||
"14": {
|
||||
"channel": 14,
|
||||
"name": "Pan SX",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": 0.85,
|
||||
"pan": 0
|
||||
},
|
||||
"15": {
|
||||
"channel": 15,
|
||||
"name": "Pan dx",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"level_db": 0.45,
|
||||
"pan": 0
|
||||
},
|
||||
"16": {
|
||||
@@ -136,8 +136,8 @@
|
||||
"20": {
|
||||
"channel": 20,
|
||||
"name": "Tast",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": 0.15,
|
||||
"pan": 0
|
||||
},
|
||||
"21": {
|
||||
@@ -157,15 +157,15 @@
|
||||
"23": {
|
||||
"channel": 23,
|
||||
"name": " Vox3",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": 4.6,
|
||||
"pan": 0
|
||||
},
|
||||
"24": {
|
||||
"channel": 24,
|
||||
"name": "Chit cnt",
|
||||
"on": false,
|
||||
"level_db": null,
|
||||
"on": true,
|
||||
"level_db": -1.95,
|
||||
"pan": 0
|
||||
},
|
||||
"25": {
|
||||
@@ -185,7 +185,7 @@
|
||||
"27": {
|
||||
"channel": 27,
|
||||
"name": "Vox 4",
|
||||
"on": false,
|
||||
"on": true,
|
||||
"level_db": null,
|
||||
"pan": 0
|
||||
},
|
||||
@@ -228,14 +228,14 @@
|
||||
"channel": 33,
|
||||
"name": "PC",
|
||||
"on": true,
|
||||
"level_db": -17.5,
|
||||
"level_db": -92.0,
|
||||
"pan": 0
|
||||
},
|
||||
"34": {
|
||||
"channel": 34,
|
||||
"name": "PC",
|
||||
"on": true,
|
||||
"level_db": -17.5,
|
||||
"level_db": -92.0,
|
||||
"pan": 63
|
||||
},
|
||||
"35": {
|
||||
@@ -328,7 +328,7 @@
|
||||
"mix": 8,
|
||||
"name": "Aux 8",
|
||||
"on": true,
|
||||
"level_db": -9.0
|
||||
"level_db": -2.15
|
||||
},
|
||||
"9": {
|
||||
"mix": 9,
|
||||
@@ -403,12 +403,71 @@
|
||||
"level_db": 6.0
|
||||
}
|
||||
},
|
||||
"timestamp": 1770577942.7806144,
|
||||
"timestamp": 1776106373.6489363,
|
||||
"steinch": {},
|
||||
"fxrtn": {},
|
||||
"dcas": {},
|
||||
"matrices": {},
|
||||
"stereo": {},
|
||||
"mono": {},
|
||||
"fxrtn": {
|
||||
"1": {
|
||||
"channel": 1,
|
||||
"level_db": -18.0
|
||||
},
|
||||
"2": {
|
||||
"channel": 2,
|
||||
"level_db": -18.0
|
||||
},
|
||||
"3": {
|
||||
"channel": 3,
|
||||
"on": true,
|
||||
"level_db": -20.0
|
||||
},
|
||||
"4": {
|
||||
"channel": 4,
|
||||
"on": true,
|
||||
"level_db": -20.0
|
||||
}
|
||||
},
|
||||
"dcas": {
|
||||
"3": {
|
||||
"dca": 3,
|
||||
"level_db": 0.25
|
||||
},
|
||||
"4": {
|
||||
"dca": 4,
|
||||
"level_db": 1.2
|
||||
},
|
||||
"2": {
|
||||
"dca": 2,
|
||||
"level_db": 1.3
|
||||
}
|
||||
},
|
||||
"matrices": {
|
||||
"3": {
|
||||
"matrix": 3,
|
||||
"level_db": -30.5
|
||||
},
|
||||
"1": {
|
||||
"matrix": 1,
|
||||
"level_db": -17.3
|
||||
},
|
||||
"2": {
|
||||
"matrix": 2,
|
||||
"level_db": -16.8
|
||||
}
|
||||
},
|
||||
"stereo": {
|
||||
"1": {
|
||||
"bus": 1,
|
||||
"name": "Stereo",
|
||||
"on": true,
|
||||
"level_db": -2.15
|
||||
},
|
||||
"2": {
|
||||
"bus": 2,
|
||||
"on": true,
|
||||
"level_db": -2.15
|
||||
}
|
||||
},
|
||||
"mono": {
|
||||
"on": true
|
||||
},
|
||||
"mute_masters": {}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,6 +11,7 @@ import time
|
||||
DEFAULT_PORT = 49280
|
||||
TF5_INPUT_CHANNELS = 40
|
||||
TF5_MIX_BUSSES = 20
|
||||
TF5_STEREO_BUSSES = 1
|
||||
|
||||
# Configurazione cache
|
||||
CACHE_DIR = Path(".tf5_mixer_cache")
|
||||
|
||||
+80
-80
File diff suppressed because one or more lines are too long
@@ -0,0 +1,249 @@
|
||||
# main.py
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import uvicorn
|
||||
|
||||
# Importa la tua classe
|
||||
# Assicurati che il nome file sia corretto, lo rinomino per chiarezza
|
||||
from mixer_controller import TF5MixerController
|
||||
|
||||
app = FastAPI(title="Yamaha TF5 Web Interface")
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
mixer_controller = None
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: list[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_text(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
global mixer_controller
|
||||
mixer_controller = TF5MixerController(host="192.168.1.57")
|
||||
|
||||
asyncio.create_task(broadcast_mixer_state())
|
||||
# === NUOVA RIGA ===
|
||||
asyncio.create_task(broadcast_meter_data()) # Avvia il broadcast dei meter
|
||||
|
||||
@app.on_event("shutdown")
|
||||
def shutdown_event():
|
||||
if mixer_controller:
|
||||
mixer_controller.close()
|
||||
|
||||
# NUOVA FUNZIONE PER I METER
|
||||
async def broadcast_meter_data():
|
||||
"""Invia i dati dei meter in tempo reale via WebSocket."""
|
||||
while True:
|
||||
if mixer_controller and mixer_controller._is_connected.is_set() and manager.active_connections:
|
||||
try:
|
||||
# Esegui la funzione bloccante in un thread separato
|
||||
snapshot = await asyncio.to_thread(mixer_controller.get_meter_snapshot)
|
||||
await manager.broadcast(json.dumps({"type": "meter_update", "data": snapshot}))
|
||||
except Exception as e:
|
||||
print(f"Errore durante il broadcast dei meter: {e}")
|
||||
|
||||
await asyncio.sleep(0.1) # Aggiorna 10 volte al secondo
|
||||
|
||||
async def broadcast_mixer_state():
|
||||
"""Controlla se la cache del mixer è cambiata e invia lo stato al WebSocket."""
|
||||
last_timestamp = 0
|
||||
while True:
|
||||
if mixer_controller and mixer_controller._is_connected.is_set():
|
||||
with mixer_controller._cache_lock:
|
||||
current_timestamp = mixer_controller._cache.get("timestamp", 0)
|
||||
|
||||
if current_timestamp > last_timestamp:
|
||||
state = {
|
||||
"channels": mixer_controller.get_all_channels_summary().get("channels", []),
|
||||
"steinch": mixer_controller.get_all_steinch_summary().get("channels", []),
|
||||
"mixes": mixer_controller.get_all_mixes_summary().get("mixes", []),
|
||||
"dcas": mixer_controller.get_all_dca_summary().get("dcas", []),
|
||||
"fxrtn": mixer_controller.get_all_fxrtn_summary().get("channels", []),
|
||||
"stereo": mixer_controller.get_stereo_info(1).get("level_db", -120),
|
||||
"stereo_on": mixer_controller.get_stereo_info(1).get("on", False)
|
||||
}
|
||||
await manager.broadcast(json.dumps({"type": "state_update", "data": state}))
|
||||
last_timestamp = current_timestamp
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@app.get("/")
|
||||
async def get():
|
||||
with open("static/index.html", "r", encoding="utf-8") as f:
|
||||
return HTMLResponse(f.read())
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await manager.connect(websocket)
|
||||
# Invia lo stato iniziale appena si connette
|
||||
with mixer_controller._cache_lock:
|
||||
mixer_controller._cache['timestamp'] = time.time() # Forza l'invio
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
cmd = json.loads(data)
|
||||
await handle_command(cmd)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
def get_actual_level(response_str: str, fallback_db: float) -> float:
|
||||
try:
|
||||
parts = response_str.strip().split()
|
||||
for part in reversed(parts):
|
||||
try:
|
||||
val_int = int(part)
|
||||
return val_int / 100.0 if val_int > -32768 else float('-inf')
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
return fallback_db
|
||||
|
||||
async def handle_command(cmd: dict):
|
||||
action = cmd.get("action")
|
||||
target_type = cmd.get("type")
|
||||
value = cmd.get("value")
|
||||
|
||||
raw_id = cmd.get("id", 1)
|
||||
try:
|
||||
ch_id = int(raw_id)
|
||||
except ValueError:
|
||||
ch_id = 1
|
||||
|
||||
# ==========================================
|
||||
# 1. GESTIONE LIVELLI (MAIN FADERS)
|
||||
# ==========================================
|
||||
if action == "set_level":
|
||||
val_float = float(value)
|
||||
res = {}
|
||||
if target_type == "channel": res = await asyncio.to_thread(mixer_controller.set_channel_level, ch_id, val_float)
|
||||
elif target_type == "steinch": res = await asyncio.to_thread(mixer_controller.set_steinch_level, ch_id, val_float)
|
||||
elif target_type == "mix": res = await asyncio.to_thread(mixer_controller.set_mix_level, ch_id, val_float)
|
||||
elif target_type == "dca": res = await asyncio.to_thread(mixer_controller.set_dca_level, ch_id, val_float)
|
||||
elif target_type == "fxrtn": res = await asyncio.to_thread(mixer_controller.set_fxrtn_level, ch_id, val_float)
|
||||
elif target_type == "stereo": res = await asyncio.to_thread(mixer_controller.set_stereo_level, 1, val_float)
|
||||
|
||||
actual_db = get_actual_level(res.get("response", ""), val_float)
|
||||
|
||||
with mixer_controller._cache_lock:
|
||||
cache_key = "channels" if target_type == "channel" else target_type
|
||||
if target_type == "mix": cache_key = "mixes"
|
||||
if target_type == "dca": cache_key = "dcas"
|
||||
|
||||
str_id = str(ch_id)
|
||||
if target_type == "stereo":
|
||||
mixer_controller._cache.setdefault("stereo", {}).setdefault("1", {})["level_db"] = actual_db
|
||||
elif cache_key in mixer_controller._cache and str_id in mixer_controller._cache[cache_key]:
|
||||
mixer_controller._cache[cache_key][str_id]["level_db"] = actual_db
|
||||
mixer_controller._cache['timestamp'] = time.time()
|
||||
|
||||
# ==========================================
|
||||
# 2. GESTIONE ON/OFF (MAIN FADERS)
|
||||
# ==========================================
|
||||
elif action == "set_on":
|
||||
val_bool = bool(value)
|
||||
if target_type == "channel": await asyncio.to_thread(mixer_controller.set_channel_on_off, ch_id, val_bool)
|
||||
elif target_type == "steinch": await asyncio.to_thread(mixer_controller.set_steinch_on_off, ch_id, val_bool)
|
||||
elif target_type == "mix": await asyncio.to_thread(mixer_controller.set_mix_on_off, ch_id, val_bool)
|
||||
elif target_type == "dca": await asyncio.to_thread(mixer_controller.set_dca_on_off, ch_id, val_bool)
|
||||
elif target_type == "fxrtn": await asyncio.to_thread(mixer_controller.set_fxrtn_on_off, ch_id, val_bool)
|
||||
elif target_type == "stereo": await asyncio.to_thread(mixer_controller.set_stereo_on_off, 1, val_bool)
|
||||
|
||||
with mixer_controller._cache_lock:
|
||||
cache_key = "channels" if target_type == "channel" else target_type
|
||||
if target_type == "mix": cache_key = "mixes"
|
||||
if target_type == "dca": cache_key = "dcas"
|
||||
|
||||
str_id = str(ch_id)
|
||||
if target_type == "stereo":
|
||||
mixer_controller._cache.setdefault("stereo", {}).setdefault("1", {})["on"] = val_bool
|
||||
elif cache_key in mixer_controller._cache and str_id in mixer_controller._cache[cache_key]:
|
||||
mixer_controller._cache[cache_key][str_id]["on"] = val_bool
|
||||
mixer_controller._cache['timestamp'] = time.time()
|
||||
|
||||
# ==========================================
|
||||
# 3. GESTIONE "SENDS ON FADERS" (NUOVO!)
|
||||
# ==========================================
|
||||
elif action == "get_mix_sends":
|
||||
target_mix = int(cmd.get("mix", 1))
|
||||
|
||||
# Funzione per interrogare velocemente tutti i 40 canali per un mix specifico
|
||||
def fetch_sends():
|
||||
sends = []
|
||||
for ch in range(1, 41): # 40 Input Channels
|
||||
try:
|
||||
lvl_cmd = f"get MIXER:Current/InCh/ToMix/Level {ch-1} {target_mix-1}"
|
||||
on_cmd = f"get MIXER:Current/InCh/ToMix/On {ch-1} {target_mix-1}"
|
||||
|
||||
lvl_raw = mixer_controller._send_command(lvl_cmd)
|
||||
on_raw = mixer_controller._send_command(on_cmd)
|
||||
|
||||
lvl_db = mixer_controller._internal_to_level(mixer_controller._parse_value(lvl_raw))
|
||||
|
||||
# --- LA CORREZIONE È QUI ---
|
||||
# Usa il sanitizzatore per convertire -Infinity in -120.0
|
||||
lvl_db = mixer_controller._sanitize_value(lvl_db)
|
||||
|
||||
is_on = mixer_controller._parse_value(on_raw) == "1"
|
||||
|
||||
# Prendi il nome dal canale master nella cache
|
||||
name = f"CH {ch}"
|
||||
with mixer_controller._cache_lock:
|
||||
if str(ch) in mixer_controller._cache.get("channels", {}):
|
||||
name = mixer_controller._cache["channels"][str(ch)]["name"]
|
||||
|
||||
sends.append({
|
||||
"id": ch,
|
||||
"name": name,
|
||||
"level_db": lvl_db,
|
||||
"on": is_on
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return sends
|
||||
|
||||
# Esegui in background per non bloccare FastAPI
|
||||
sends_data = await asyncio.to_thread(fetch_sends)
|
||||
await manager.broadcast(json.dumps({"type": "mix_sends_data", "mix": target_mix, "data": sends_data}))
|
||||
|
||||
elif action == "set_send_level":
|
||||
target_mix = int(cmd.get("mix", 1))
|
||||
val_float = float(value)
|
||||
# Invia e recupera la risposta (per i log e l'allineamento)
|
||||
res = await asyncio.to_thread(mixer_controller.set_channel_to_mix_level, ch_id, target_mix, val_float)
|
||||
|
||||
# Logghiamo l'effettivo valore applicato dal mixer (OKm)
|
||||
actual_db = get_actual_level(res.get("response", ""), val_float)
|
||||
print(f"DEBUG: Mandata CH{ch_id} -> MIX{target_mix} impostata a {actual_db} dB")
|
||||
|
||||
elif action == "set_send_on":
|
||||
target_mix = int(cmd.get("mix", 1))
|
||||
val_bool = bool(value)
|
||||
await asyncio.to_thread(mixer_controller.set_channel_to_mix_on_off, ch_id, target_mix, val_bool)
|
||||
print(f"DEBUG: Mandata CH{ch_id} -> MIX{target_mix} {'ACCESA' if val_bool else 'SPENTA'}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
+216
-248
@@ -32,6 +32,8 @@ class TF5MixerController:
|
||||
self._is_running = threading.Event()
|
||||
self._is_connected = threading.Event()
|
||||
|
||||
self._init_meter_state()
|
||||
|
||||
self.start()
|
||||
|
||||
def start(self):
|
||||
@@ -63,15 +65,22 @@ class TF5MixerController:
|
||||
|
||||
with self._cache_lock:
|
||||
is_cache_empty = not self._cache.get("channels")
|
||||
|
||||
if is_cache_empty:
|
||||
print("Cache iniziale vuota. Avvio refresh completo...")
|
||||
self.refresh_cache()
|
||||
|
||||
self._socket_reader()
|
||||
# Piccola pausa per assicurarsi che il buffer sia pulito
|
||||
time.sleep(0.2)
|
||||
|
||||
# Sottoscrivi DOPO il refresh (cache occupa il socket, meter devono aspettare)
|
||||
self.subscribe_meters(interval_ms=100)
|
||||
|
||||
self._socket_reader() # blocca qui finché connesso
|
||||
|
||||
except socket.error as e:
|
||||
print(f"🔥 Errore di connessione: {e}. Riprovo tra 5 secondi...")
|
||||
finally:
|
||||
self.unsubscribe_meters() # tenta di cancellare prima di chiudere
|
||||
self._is_connected.clear()
|
||||
if self.socket:
|
||||
self.socket.close()
|
||||
@@ -112,6 +121,16 @@ class TF5MixerController:
|
||||
print(f"🔥 Errore di lettura dal socket: {e}")
|
||||
break
|
||||
|
||||
def _send_raw(self, command: str):
|
||||
"""Invia un comando senza attendere risposta."""
|
||||
if not self._is_connected.is_set():
|
||||
return
|
||||
try:
|
||||
print(f"SEND (raw): {command}")
|
||||
self.socket.sendall((command + '\n').encode('utf-8'))
|
||||
except socket.error as e:
|
||||
print(f"⚠️ Errore invio raw: {e}")
|
||||
|
||||
def _handle_notify(self, message: str):
|
||||
"""
|
||||
Analizza un messaggio NOTIFY e aggiorna la cache in modo robusto.
|
||||
@@ -120,6 +139,11 @@ class TF5MixerController:
|
||||
print(f"RECV NOTIFY: {message}")
|
||||
|
||||
parts = message.split()
|
||||
|
||||
if len(parts) >= 2 and parts[1] == "mtrinfo":
|
||||
self._handle_meter_notify(message)
|
||||
return
|
||||
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
@@ -371,6 +395,68 @@ class TF5MixerController:
|
||||
finally:
|
||||
print(f" -> LOCK RILASCIATO.")
|
||||
|
||||
def _handle_prminfo_notify(self, message: str):
|
||||
"""
|
||||
Gestisce: NOTIFY prminfo <meter_id> <val0> <val1> ... <valN>
|
||||
I valori sono: [ch0_type0, ch0_type1, ..., ch1_type0, ch1_type1, ...]
|
||||
cioè interleaved per canale.
|
||||
"""
|
||||
parts = message.split()
|
||||
# parts[0]="NOTIFY", parts[1]="prminfo", parts[2]=meter_id, parts[3:]=values
|
||||
if len(parts) < 4:
|
||||
return
|
||||
try:
|
||||
meter_id = int(parts[2])
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if meter_id not in self._METER_SUBSCRIPTIONS:
|
||||
return
|
||||
|
||||
info = self._METER_SUBSCRIPTIONS[meter_id]
|
||||
key = info["key"]
|
||||
num_ch = info["channels"]
|
||||
num_types = info["types"]
|
||||
|
||||
try:
|
||||
values = [int(v) for v in parts[3:]]
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
expected = num_ch * num_types
|
||||
if len(values) < expected:
|
||||
return
|
||||
|
||||
with self._meter_lock:
|
||||
for ch in range(num_ch):
|
||||
for t in range(num_types):
|
||||
idx = ch * num_types + t
|
||||
self._meter_data[key][t][ch] = values[idx]
|
||||
|
||||
def _handle_meter_notify(self, message: str):
|
||||
parts = message.split()
|
||||
# NOTIFY mtrinfo <id> <val0> <val1> ...
|
||||
try:
|
||||
meter_id = int(parts[2])
|
||||
except (IndexError, ValueError):
|
||||
return
|
||||
if meter_id not in self._METER_SUBSCRIPTIONS:
|
||||
return
|
||||
info = self._METER_SUBSCRIPTIONS[meter_id]
|
||||
key = info["key"]
|
||||
num_ch = info["channels"]
|
||||
num_types = info["types"]
|
||||
try:
|
||||
values = [int(v) for v in parts[3:]]
|
||||
except ValueError:
|
||||
return
|
||||
if len(values) < num_ch * num_types:
|
||||
return
|
||||
with self._meter_lock:
|
||||
for ch in range(num_ch):
|
||||
for t in range(num_types):
|
||||
self._meter_data[key][t][ch] = values[ch * num_types + t]
|
||||
|
||||
def _send_command(self, command: str) -> str:
|
||||
"""Invia un comando al mixer e attende la risposta."""
|
||||
if not self._is_connected.wait(timeout=5):
|
||||
@@ -788,16 +874,20 @@ class TF5MixerController:
|
||||
def get_all_channels_summary(self) -> dict:
|
||||
if not self._is_cache_valid(): self.refresh_cache()
|
||||
with self._cache_lock:
|
||||
channels_copy = list(self._cache.get("channels", {}).values())
|
||||
items_copy = list(self._cache.get("channels", {}).values())
|
||||
cache_age = time.time() - self._cache.get("timestamp", 0)
|
||||
channels = sorted([
|
||||
{"channel": info["channel"], "name": info["name"], "on": info["on"],
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))}
|
||||
for info in channels_copy
|
||||
], key=lambda x: x["channel"])
|
||||
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)"}
|
||||
|
||||
results = []
|
||||
for info in items_copy:
|
||||
ch_num = info.get("channel", 0)
|
||||
results.append({
|
||||
"channel": ch_num,
|
||||
"name": info.get("name") or f"CH {ch_num}",
|
||||
"on": info.get("on", False),
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))
|
||||
})
|
||||
results.sort(key=lambda x: x["channel"])
|
||||
return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)}
|
||||
|
||||
def mute_multiple_channels(self, channels: List[int]) -> dict:
|
||||
results = [self.set_channel_on_off(ch, False) for ch in channels]
|
||||
@@ -963,19 +1053,22 @@ class TF5MixerController:
|
||||
"message": f"StInCh {channel} → Mono send {'acceso' if state else 'spento'}", "response": response}
|
||||
|
||||
def get_all_steinch_summary(self) -> dict:
|
||||
"""Restituisce il riepilogo di tutti i canali stereo di ingresso dalla cache."""
|
||||
if not self._is_cache_valid(): self.refresh_cache()
|
||||
with self._cache_lock:
|
||||
steinch_copy = list(self._cache.get("steinch", {}).values())
|
||||
items_copy = list(self._cache.get("steinch", {}).values())
|
||||
cache_age = time.time() - self._cache.get("timestamp", 0)
|
||||
channels = sorted([
|
||||
{"channel": info["channel"], "name": info["name"], "on": info["on"],
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))}
|
||||
for info in steinch_copy
|
||||
], key=lambda x: x["channel"])
|
||||
return {"status": "success", "total_channels": len(channels), "channels": channels,
|
||||
"cache_age_seconds": int(cache_age),
|
||||
"message": f"Riepilogo di {len(channels)} canali stereo (cache: {int(cache_age)}s fa)"}
|
||||
|
||||
results = []
|
||||
for info in items_copy:
|
||||
ch_num = info.get("channel", 0)
|
||||
results.append({
|
||||
"channel": ch_num,
|
||||
"name": info.get("name") or f"ST IN {ch_num}",
|
||||
"on": info.get("on", False),
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))
|
||||
})
|
||||
results.sort(key=lambda x: x["channel"])
|
||||
return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)}
|
||||
|
||||
# =========================================================================
|
||||
# FX RETURN CHANNELS (FxRtnCh)
|
||||
@@ -1090,19 +1183,22 @@ class TF5MixerController:
|
||||
"message": f"FxRtnCh {channel} → St pan impostato a {pan_value}", "response": response}
|
||||
|
||||
def get_all_fxrtn_summary(self) -> dict:
|
||||
"""Restituisce il riepilogo di tutti i canali FX return dalla cache."""
|
||||
if not self._is_cache_valid(): self.refresh_cache()
|
||||
with self._cache_lock:
|
||||
fxrtn_copy = list(self._cache.get("fxrtn", {}).values())
|
||||
items_copy = list(self._cache.get("fxrtn", {}).values())
|
||||
cache_age = time.time() - self._cache.get("timestamp", 0)
|
||||
channels = sorted([
|
||||
{"channel": info["channel"], "name": info["name"], "on": info["on"],
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))}
|
||||
for info in fxrtn_copy
|
||||
], key=lambda x: x["channel"])
|
||||
return {"status": "success", "total_channels": len(channels), "channels": channels,
|
||||
"cache_age_seconds": int(cache_age),
|
||||
"message": f"Riepilogo di {len(channels)} canali FX return (cache: {int(cache_age)}s fa)"}
|
||||
|
||||
results = []
|
||||
for info in items_copy:
|
||||
ch_num = info.get("channel", 0)
|
||||
results.append({
|
||||
"channel": ch_num,
|
||||
"name": info.get("name") or f"FX RTN {ch_num}",
|
||||
"on": info.get("on", False),
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))
|
||||
})
|
||||
results.sort(key=lambda x: x["channel"])
|
||||
return {"status": "success", "total_channels": len(results), "channels": results, "cache_age_seconds": int(cache_age)}
|
||||
|
||||
# =========================================================================
|
||||
# DCA GROUPS
|
||||
@@ -1158,14 +1254,26 @@ class TF5MixerController:
|
||||
with self._cache_lock:
|
||||
dcas_copy = list(self._cache.get("dcas", {}).values())
|
||||
cache_age = time.time() - self._cache.get("timestamp", 0)
|
||||
dcas = sorted([
|
||||
{"dca": info["dca"], "name": info["name"], "on": info["on"],
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))}
|
||||
for info in dcas_copy
|
||||
], key=lambda x: x["dca"])
|
||||
|
||||
dcas = []
|
||||
for info in dcas_copy:
|
||||
try:
|
||||
# Usiamo .get() con valori di default per evitare KeyError
|
||||
dca_num = info.get("dca", 0)
|
||||
dcas.append({
|
||||
"dca": dca_num,
|
||||
"name": info.get("name", f"DCA {dca_num}"), # Se manca il nome, usa "DCA 1" ecc.
|
||||
"on": info.get("on", False), # Se manca lo stato, usa False
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"⚠️ Errore nel processare DCA info: {e}")
|
||||
|
||||
dcas.sort(key=lambda x: x["dca"])
|
||||
|
||||
return {"status": "success", "total_dcas": len(dcas), "dcas": dcas,
|
||||
"cache_age_seconds": int(cache_age),
|
||||
"message": f"Riepilogo di {len(dcas)} DCA (cache: {int(cache_age)}s fa)"}
|
||||
"message": f"Riepilogo di {len(dcas)} DCA"}
|
||||
|
||||
# =========================================================================
|
||||
# MIX BUSES
|
||||
@@ -1248,16 +1356,20 @@ class TF5MixerController:
|
||||
def get_all_mixes_summary(self) -> dict:
|
||||
if not self._is_cache_valid(): self.refresh_cache()
|
||||
with self._cache_lock:
|
||||
mixes_copy = list(self._cache.get("mixes", {}).values())
|
||||
items_copy = list(self._cache.get("mixes", {}).values())
|
||||
cache_age = time.time() - self._cache.get("timestamp", 0)
|
||||
mixes = sorted([
|
||||
{"mix": info["mix"], "name": info["name"], "on": info["on"],
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))}
|
||||
for info in mixes_copy
|
||||
], key=lambda x: x["mix"])
|
||||
return {"status": "success", "total_mixes": len(mixes), "mixes": mixes,
|
||||
"cache_age_seconds": int(cache_age),
|
||||
"message": f"Riepilogo di {len(mixes)} mix (cache: {int(cache_age)}s fa)"}
|
||||
|
||||
results = []
|
||||
for info in items_copy:
|
||||
mix_num = info.get("mix", 0)
|
||||
results.append({
|
||||
"mix": mix_num,
|
||||
"name": info.get("name") or f"MIX {mix_num}",
|
||||
"on": info.get("on", False),
|
||||
"level_db": self._sanitize_value(self._normalize_level_from_cache(info.get("level_db")))
|
||||
})
|
||||
results.sort(key=lambda x: x["mix"])
|
||||
return {"status": "success", "total_mixes": len(results), "mixes": results, "cache_age_seconds": int(cache_age)}
|
||||
|
||||
def search_mixes_by_name(self, search_term: str) -> dict:
|
||||
if not self._is_cache_valid(): self.refresh_cache()
|
||||
@@ -1585,212 +1697,68 @@ class TF5MixerController:
|
||||
"message": f"Scena salvata nel banco {bank.upper()}{scene_number}.", "response": response}
|
||||
|
||||
# =========================================================================
|
||||
# METER READING
|
||||
# Riferimento prminfo:
|
||||
# 2000 InCh 40ch 3 tipi: 0=PreHPF, 1=PreFader, 2=PostOn
|
||||
# 2001 StInCh 4ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn
|
||||
# 2002 FxRtnCh 4ch 2 tipi: 0=PreFader, 1=PostOn
|
||||
# 2100 Mix 20ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn
|
||||
# 2101 Mtrx 4ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn
|
||||
# 2102 St 2ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn
|
||||
# 2103 Mono 1ch 3 tipi: 0=PreEQ, 1=PreFader, 2=PostOn
|
||||
#
|
||||
# Il valore raw è un intero 0–127.
|
||||
# Conversione: 0 = -inf dB, 1–127 lineare su scala ~-72..+18 dBFS.
|
||||
# Formula Yamaha TF: dBFS = (raw / 127) * 90 - 72 (approssimazione lineare)
|
||||
# Oppure si restituisce direttamente il raw per display proporzionale.
|
||||
# METER READING (via prminfo subscription)
|
||||
# =========================================================================
|
||||
|
||||
# Mappa dei meter disponibili
|
||||
_METER_DEFS = {
|
||||
"InCh": {"path": "MIXER:Current/Meter/InCh", "channels": 40, "types": ["PreHPF", "PreFader", "PostOn"]},
|
||||
"StInCh": {"path": "MIXER:Current/Meter/StInCh", "channels": 4, "types": ["PreEQ", "PreFader", "PostOn"]},
|
||||
"FxRtnCh": {"path": "MIXER:Current/Meter/FxRtnCh", "channels": 4, "types": ["PreFader", "PostOn"]},
|
||||
"Mix": {"path": "MIXER:Current/Meter/Mix", "channels": 20, "types": ["PreEQ", "PreFader", "PostOn"]},
|
||||
"Mtrx": {"path": "MIXER:Current/Meter/Mtrx", "channels": 4, "types": ["PreEQ", "PreFader", "PostOn"]},
|
||||
"St": {"path": "MIXER:Current/Meter/St", "channels": 2, "types": ["PreEQ", "PreFader", "PostOn"]},
|
||||
"Mono": {"path": "MIXER:Current/Meter/Mono", "channels": 1, "types": ["PreEQ", "PreFader", "PostOn"]},
|
||||
_METER_SUBSCRIPTIONS = {
|
||||
2000: {"key": "InCh", "channels": 40, "types": 3},
|
||||
2001: {"key": "StInCh", "channels": 4, "types": 3},
|
||||
2002: {"key": "FxRtnCh", "channels": 4, "types": 2},
|
||||
2100: {"key": "Mix", "channels": 20, "types": 3},
|
||||
2101: {"key": "Mtrx", "channels": 4, "types": 3},
|
||||
2102: {"key": "St", "channels": 2, "types": 3},
|
||||
2103: {"key": "Mono", "channels": 1, "types": 3},
|
||||
}
|
||||
|
||||
# Dizionario meter: { "InCh": [[ch1t0, ch1t1, ch1t2], [ch2t0,...], ...], ... }
|
||||
# Oppure più semplice: { "InCh": {type_idx: [val_ch1, val_ch2, ...]}, ... }
|
||||
_meter_data: dict = {}
|
||||
_meter_lock: threading.Lock = None # verrà inizializzato in __init__
|
||||
|
||||
def _init_meter_state(self):
|
||||
"""Inizializza la struttura dati per i meter."""
|
||||
self._meter_lock = threading.Lock()
|
||||
self._meter_data = {}
|
||||
for info in self._METER_SUBSCRIPTIONS.values():
|
||||
key = info["key"]
|
||||
self._meter_data[key] = {
|
||||
t: [0] * info["channels"] for t in range(info["types"])
|
||||
}
|
||||
|
||||
def subscribe_meters(self, interval_ms: int = 100):
|
||||
# Il comando corretto per i meter Yamaha TF è "mtrinfo"
|
||||
for meter_id in self._METER_SUBSCRIPTIONS:
|
||||
self._send_raw(f"mtrinfo {meter_id} {interval_ms}")
|
||||
time.sleep(0.05)
|
||||
|
||||
def unsubscribe_meters(self):
|
||||
for meter_id in self._METER_SUBSCRIPTIONS:
|
||||
self._send_raw(f"mtrinfo {meter_id} 0")
|
||||
|
||||
def get_meter_snapshot(self) -> dict:
|
||||
"""Restituisce l'ultimo snapshot meter ricevuto."""
|
||||
with self._meter_lock:
|
||||
# Costruisce il formato atteso dal frontend
|
||||
snapshot = {}
|
||||
for meter_id, info in self._METER_SUBSCRIPTIONS.items():
|
||||
key = info["key"]
|
||||
frontend_key = key.lower()
|
||||
# Il frontend si aspetta { readings: [{channel, raw, level_db}] }
|
||||
type_idx = info["types"] - 1 # Usiamo l'ultimo tipo (PostOn)
|
||||
readings = []
|
||||
for ch_idx, raw in enumerate(self._meter_data[key][type_idx]):
|
||||
readings.append({
|
||||
"channel": ch_idx + 1,
|
||||
"raw": raw,
|
||||
"level_db": self._meter_raw_to_db(raw)
|
||||
})
|
||||
snapshot[frontend_key] = {
|
||||
"readings": readings,
|
||||
"type_name": "PostOn"
|
||||
}
|
||||
return snapshot
|
||||
|
||||
def _meter_raw_to_db(self, raw: int) -> float:
|
||||
"""
|
||||
Converte il valore raw del meter (0-127) in dBFS approssimato.
|
||||
Scala Yamaha TF: 127 = +18 dBFS, 76 = 0 dBFS, 1 = -72 dBFS, 0 = -inf.
|
||||
"""
|
||||
if raw <= 0:
|
||||
return float('-inf')
|
||||
# Interpolazione lineare: 0..127 → -72..+18 dBFS (range 90 dB)
|
||||
return round((raw / 127.0) * 90.0 - 72.0, 1)
|
||||
|
||||
def _parse_meter_response(self, response: str) -> Optional[int]:
|
||||
"""Estrae il valore intero raw dal response di un comando get meter."""
|
||||
try:
|
||||
first_line = response.split('\n')[0].strip()
|
||||
parts = first_line.split()
|
||||
if parts and parts[0] == "OK":
|
||||
return int(parts[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def get_meter(self, section: str, channel: int, meter_type: int = 2) -> dict:
|
||||
"""
|
||||
Legge il livello meter di un singolo canale.
|
||||
|
||||
Args:
|
||||
section: Sezione del mixer: "InCh", "StInCh", "FxRtnCh",
|
||||
"Mix", "Mtrx", "St", "Mono"
|
||||
channel: Numero canale (1-based)
|
||||
meter_type: Indice del tipo meter (0-based, vedi _METER_DEFS per i nomi)
|
||||
|
||||
Returns:
|
||||
dict con raw (0-127), level_db (dBFS), section, channel, type_name
|
||||
"""
|
||||
if section not in self._METER_DEFS:
|
||||
return {"status": "error",
|
||||
"message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"}
|
||||
|
||||
defn = self._METER_DEFS[section]
|
||||
num_ch = defn["channels"]
|
||||
num_types = len(defn["types"])
|
||||
|
||||
if not 1 <= channel <= num_ch:
|
||||
return {"status": "error", "message": f"Canale {section} deve essere tra 1 e {num_ch}"}
|
||||
if not 0 <= meter_type < num_types:
|
||||
return {"status": "error",
|
||||
"message": f"Tipo meter deve essere tra 0 e {num_types - 1} per {section}: {defn['types']}"}
|
||||
|
||||
ch_idx = channel - 1
|
||||
command = f"get {defn['path']} {ch_idx} {meter_type}"
|
||||
response = self._send_command(command)
|
||||
raw = self._parse_meter_response(response)
|
||||
|
||||
if raw is None:
|
||||
return {"status": "error", "message": f"Risposta non valida: {response}"}
|
||||
|
||||
level_db = self._meter_raw_to_db(raw)
|
||||
type_name = defn["types"][meter_type]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"section": section,
|
||||
"channel": channel,
|
||||
"type": meter_type,
|
||||
"type_name": type_name,
|
||||
"raw": raw,
|
||||
"level_db": level_db,
|
||||
"message": f"{section} ch{channel} [{type_name}]: {raw}/127 → {level_db} dBFS"
|
||||
}
|
||||
|
||||
def get_all_meters(self, section: str, meter_type: int = 2) -> dict:
|
||||
"""
|
||||
Legge tutti i canali meter di una sezione.
|
||||
|
||||
Args:
|
||||
section: "InCh", "StInCh", "FxRtnCh", "Mix", "Mtrx", "St", "Mono"
|
||||
meter_type: Indice tipo meter (0-based)
|
||||
|
||||
Returns:
|
||||
dict con lista di letture meter per tutti i canali della sezione
|
||||
"""
|
||||
if section not in self._METER_DEFS:
|
||||
return {"status": "error",
|
||||
"message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"}
|
||||
|
||||
defn = self._METER_DEFS[section]
|
||||
num_ch = defn["channels"]
|
||||
num_types = len(defn["types"])
|
||||
|
||||
if not 0 <= meter_type < num_types:
|
||||
return {"status": "error",
|
||||
"message": f"Tipo meter deve essere tra 0 e {num_types - 1} per {section}: {defn['types']}"}
|
||||
|
||||
type_name = defn["types"][meter_type]
|
||||
readings = []
|
||||
|
||||
for ch in range(1, num_ch + 1):
|
||||
ch_idx = ch - 1
|
||||
command = f"get {defn['path']} {ch_idx} {meter_type}"
|
||||
response = self._send_command(command)
|
||||
raw = self._parse_meter_response(response)
|
||||
if raw is not None:
|
||||
level_db = self._meter_raw_to_db(raw)
|
||||
readings.append({
|
||||
"channel": ch,
|
||||
"raw": raw,
|
||||
"level_db": level_db
|
||||
})
|
||||
else:
|
||||
readings.append({
|
||||
"channel": ch,
|
||||
"raw": None,
|
||||
"level_db": None,
|
||||
"error": response
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"section": section,
|
||||
"type": meter_type,
|
||||
"type_name": type_name,
|
||||
"total_channels": num_ch,
|
||||
"readings": readings,
|
||||
"message": f"Meter {section} [{type_name}]: {num_ch} canali letti"
|
||||
}
|
||||
|
||||
def get_meter_snapshot(self, meter_type: int = 2) -> dict:
|
||||
"""
|
||||
Legge un snapshot dei meter di tutte le sezioni principali.
|
||||
|
||||
Args:
|
||||
meter_type: Tipo meter da usare per tutte le sezioni.
|
||||
Nota: FxRtnCh ha solo 2 tipi (0-1), se si passa 2
|
||||
viene automaticamente usato il tipo 1 (PostOn).
|
||||
|
||||
Returns:
|
||||
dict con un sotto-dict per ogni sezione, contenente le letture.
|
||||
"""
|
||||
snapshot = {}
|
||||
for section, defn in self._METER_DEFS.items():
|
||||
# Clamp meter_type al massimo disponibile per la sezione
|
||||
safe_type = min(meter_type, len(defn["types"]) - 1)
|
||||
result = self.get_all_meters(section, safe_type)
|
||||
snapshot[section] = result
|
||||
|
||||
sections_ok = sum(1 for r in snapshot.values() if r["status"] == "success")
|
||||
return {
|
||||
"status": "success",
|
||||
"meter_type_requested": meter_type,
|
||||
"sections": snapshot,
|
||||
"message": f"Snapshot meter: {sections_ok}/{len(self._METER_DEFS)} sezioni lette"
|
||||
}
|
||||
|
||||
def get_meter_types(self, section: str = None) -> dict:
|
||||
"""
|
||||
Restituisce i tipi di meter disponibili per una sezione (o tutte).
|
||||
|
||||
Args:
|
||||
section: Nome sezione opzionale. Se None, restituisce tutte.
|
||||
"""
|
||||
if section:
|
||||
if section not in self._METER_DEFS:
|
||||
return {"status": "error",
|
||||
"message": f"Sezione '{section}' non valida. Valori: {list(self._METER_DEFS.keys())}"}
|
||||
defn = self._METER_DEFS[section]
|
||||
return {
|
||||
"status": "success",
|
||||
"section": section,
|
||||
"channels": defn["channels"],
|
||||
"types": {i: name for i, name in enumerate(defn["types"])}
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "success",
|
||||
"sections": {
|
||||
sec: {
|
||||
"channels": d["channels"],
|
||||
"types": {i: name for i, name in enumerate(d["types"])}
|
||||
}
|
||||
for sec, d in self._METER_DEFS.items()
|
||||
}
|
||||
}
|
||||
return round((raw / 127.0) * 90.0 - 72.0, 1)
|
||||
@@ -0,0 +1,389 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TF5 Web Mixer</title>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
user-select: none;
|
||||
overflow: hidden; /* Evita lo scroll dell'intera pagina */
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical] {
|
||||
appearance: slider-vertical;
|
||||
width: 40px;
|
||||
height: 250px;
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Colori Fader */
|
||||
.fader-main { accent-color: #3b82f6; }
|
||||
.fader-send { accent-color: #f59e0b; }
|
||||
.fader-stereo { accent-color: #ef4444; }
|
||||
|
||||
.fader-strip {
|
||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
|
||||
border: 1px solid #374151;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.btn-on {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
border-color: #991b1b;
|
||||
}
|
||||
|
||||
.btn-off {
|
||||
background-color: #374151;
|
||||
color: #9ca3af;
|
||||
border-color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.mix-btn.active {
|
||||
background-color: #f59e0b;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
border-color: #d97706;
|
||||
}
|
||||
|
||||
/* Stili per i Meter Audio */
|
||||
.meter-track {
|
||||
width: 12px;
|
||||
height: 250px;
|
||||
background-color: #111827;
|
||||
border: 1px solid #4b5563;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.meter-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #4ade80, #facc15 85%, #ef4444 95%);
|
||||
transition: height 0.05s linear; /* Transizione fluida */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" class="h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="bg-gray-900 p-4 shadow-lg border-b border-gray-800 flex justify-between items-center flex-none">
|
||||
<h1 class="text-2xl font-bold tracking-wider text-blue-400">YAMAHA TF5 <span class="text-gray-500 text-sm">Web Controller</span></h1>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span :class="wsConnected ? 'animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75' : ''"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3" :class="wsConnected ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
</span>
|
||||
{{ wsConnected ? 'Connesso' : 'Disconnesso' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Navigation Main -->
|
||||
<nav class="flex overflow-x-auto bg-gray-800 p-2 gap-4 flex-none">
|
||||
<button v-for="tab in tabs" :key="tab.id" @click="activeTab = tab.id"
|
||||
class="nav-tab px-4 py-2 font-semibold uppercase text-sm whitespace-nowrap" :class="{'active': activeTab === tab.id}">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Sub-Navigation per SENDS ON FADERS -->
|
||||
<div v-if="activeTab === 'mix_sends'" class="bg-gray-900 p-2 border-b border-gray-700 flex overflow-x-auto gap-2 items-center flex-none">
|
||||
<span class="text-gray-400 text-sm font-bold ml-2 mr-2 whitespace-nowrap">SELEZIONA MIX:</span>
|
||||
<button v-for="i in 20" :key="'mix'+i" @click="loadMixSends(i)"
|
||||
class="mix-btn px-3 py-1 rounded border border-gray-600 text-sm text-gray-300 transition-colors"
|
||||
:class="{'active': activeMixId === i}">
|
||||
M{{ i }}
|
||||
</button>
|
||||
<div v-if="isLoadingSends" class="ml-4 text-yellow-500 text-sm animate-pulse">Caricamento fader...</div>
|
||||
</div>
|
||||
|
||||
<!-- Mixer Surface -->
|
||||
<main class="flex-1 flex overflow-hidden bg-gray-950">
|
||||
|
||||
<!-- ZONA CANALI (Scorrevole) -->
|
||||
<div class="flex-1 overflow-x-auto p-6 flex gap-4 items-start">
|
||||
|
||||
<div v-if="currentFaders.length === 0 && !isLoadingSends" class="text-gray-500 m-auto text-center w-full mt-20">
|
||||
Nessun fader disponibile in questa vista.
|
||||
</div>
|
||||
|
||||
<!-- Fader strips -->
|
||||
<div v-for="fader in currentFaders" :key="fader.id"
|
||||
class="fader-strip rounded-lg p-3 flex flex-col items-center min-w-[80px]"
|
||||
:class="{'border-yellow-700': activeTab === 'mix_sends'}">
|
||||
|
||||
<div class="h-10 mb-2 w-full text-center overflow-hidden text-ellipsis px-1 border border-gray-700 bg-gray-800 rounded flex items-center justify-center font-bold text-xs">
|
||||
{{ fader.name }}
|
||||
</div>
|
||||
|
||||
<button @click="toggleOn(fader.id, fader.on)"
|
||||
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
||||
:class="fader.on ? 'btn-on' : 'btn-off'">ON</button>
|
||||
|
||||
<!-- Contenitore Fader + Meter -->
|
||||
<div class="flex items-end gap-2 mb-4">
|
||||
<!-- Meter -->
|
||||
<div class="meter-track">
|
||||
<div class="meter-bar" :style="{ height: getMeterHeight(fader.id) + '%' }"></div>
|
||||
</div>
|
||||
<!-- Fader -->
|
||||
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
||||
<div class="absolute left-1 top-0 bottom-0 w-1 flex flex-col justify-between py-2 text-[8px] text-gray-500 font-bold">
|
||||
<span>+10</span><span>0</span><span>-20</span><span>-60</span><span>-∞</span>
|
||||
</div>
|
||||
<input type="range" orient="vertical" min="0" max="100" step="0.1"
|
||||
:class="activeTab === 'mix_sends' ? 'fader-send' : 'fader-main'" :value="fader.slider_pos"
|
||||
@pointerdown="startDrag(fader.id)" @pointerup="stopDrag(fader.id)"
|
||||
@pointerleave="stopDrag(fader.id)" @input="handleSliderMove(fader.id, $event.target.value)"
|
||||
@change="sendFinalLevel(fader.id)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-black font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700"
|
||||
:class="activeTab === 'mix_sends' ? 'text-yellow-400' : 'text-green-400'">
|
||||
{{ formatDb(fader.level_db) }}
|
||||
</div>
|
||||
<div class="text-gray-500 text-[10px] mt-2 font-bold uppercase">{{ getFaderLabel(fader.id) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-[20px] h-full"> </div>
|
||||
</div>
|
||||
|
||||
<!-- ZONA MASTER (Fissa a destra) -->
|
||||
<div class="flex-none w-[130px] p-6 bg-gray-900 border-l border-gray-800 shadow-[-10px_0_20px_rgba(0,0,0,0.5)] z-10 flex flex-col items-center">
|
||||
|
||||
<!-- MASTER DEL MIX SELEZIONATO (Sends on Faders) -->
|
||||
<div v-if="activeTab === 'mix_sends' && currentMixMaster"
|
||||
class="fader-strip rounded-lg p-3 flex flex-col items-center w-full border-yellow-600 border bg-gray-800">
|
||||
<div class="h-10 mb-2 w-full text-center bg-yellow-700 text-black rounded flex flex-col items-center justify-center font-bold text-[10px] leading-tight">
|
||||
<span>MIX {{ activeMixId }}</span>
|
||||
<span class="uppercase opacity-80 truncate w-full px-1">{{ currentMixMaster.name }}</span>
|
||||
</div>
|
||||
<button @click="toggleOn(activeMixId, currentMixMaster.on, 'mix')"
|
||||
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
||||
:class="currentMixMaster.on ? 'btn-on' : 'btn-off'">ON</button>
|
||||
<!-- Contenitore Fader + Meter Master Mix -->
|
||||
<div class="flex items-end gap-2 mb-4">
|
||||
<div class="meter-track"><div class="meter-bar" :style="{ height: getMeterHeight(activeMixId, 'mix') + '%' }"></div></div>
|
||||
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
||||
<input type="range" orient="vertical" min="0" max="100" step="0.1" class="fader-send"
|
||||
:value="currentMixMaster.slider_pos" @pointerdown="startDrag('mix-master')"
|
||||
@pointerup="stopDrag('mix-master')" @pointerleave="stopDrag('mix-master')"
|
||||
@input="handleSliderMove(activeMixId, $event.target.value, 'mix')"
|
||||
@change="sendFinalLevel(activeMixId, 'mix')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black text-yellow-400 font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700">{{ formatDb(currentMixMaster.level_db) }}</div>
|
||||
<div class="text-yellow-600 text-[10px] mt-2 font-bold uppercase">MASTER</div>
|
||||
</div>
|
||||
|
||||
<!-- MASTER STEREO (Default) -->
|
||||
<div v-if="activeTab !== 'mix_sends' && state.stereo !== undefined"
|
||||
class="fader-strip rounded-lg p-3 flex flex-col items-center w-full border-red-900 border bg-gray-800">
|
||||
<div class="h-10 mb-2 w-full text-center bg-red-900 text-white rounded flex items-center justify-center font-bold text-[10px] uppercase px-1">
|
||||
Stereo L/R</div>
|
||||
<button @click="toggleOn('stereo', state.stereo_on, 'stereo')"
|
||||
class="w-full h-10 rounded border-b-4 mb-6 font-bold text-sm transition-all"
|
||||
:class="state.stereo_on ? 'btn-on' : 'btn-off'">ON</button>
|
||||
<!-- Contenitore Fader + Meter Master Stereo -->
|
||||
<div class="flex items-end gap-2 mb-4">
|
||||
<div class="meter-track"><div class="meter-bar" :style="{ height: getMeterHeight(1, 'stereo') + '%' }"></div></div>
|
||||
<div class="relative flex justify-center bg-gray-950 p-2 rounded-lg border border-gray-800">
|
||||
<input type="range" orient="vertical" min="0" max="100" step="0.1" class="fader-stereo"
|
||||
:value="stereoFader.slider_pos" @pointerdown="startDrag('stereo')"
|
||||
@pointerup="stopDrag('stereo')" @pointerleave="stopDrag('stereo')"
|
||||
@input="handleSliderMove('stereo', $event.target.value, 'stereo')"
|
||||
@change="sendFinalLevel('stereo', 'stereo')">
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-black text-red-400 font-mono text-[10px] py-1 w-full text-center rounded border border-gray-700">{{ formatDb(state.stereo) }}</div>
|
||||
<div class="text-red-500 text-[10px] mt-2 font-bold uppercase">MAIN</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
const mapRange = (val, in_min, in_max, out_min, out_max) => (val - in_min) * (out_max - out_min) / (in_max - in_min) + out_min;
|
||||
function sliderToDb(pos) {
|
||||
pos = parseFloat(pos);
|
||||
if (pos <= 0) return -120;
|
||||
if (pos <= 20) return mapRange(pos, 0, 20, -120, -60);
|
||||
if (pos <= 50) return mapRange(pos, 20, 50, -60, -20);
|
||||
if (pos <= 80) return mapRange(pos, 50, 80, -20, 0);
|
||||
return mapRange(pos, 80, 100, 0, 10);
|
||||
}
|
||||
function dbToSlider(db) {
|
||||
db = parseFloat(db);
|
||||
if (db <= -120) return 0;
|
||||
if (db <= -60) return mapRange(db, -120, -60, 0, 20);
|
||||
if (db <= -20) return mapRange(db, -60, -20, 20, 50);
|
||||
if (db <= 0) return mapRange(db, -20, 0, 50, 80);
|
||||
if (db <= 10) return mapRange(db, 0, 10, 80, 100);
|
||||
return 100;
|
||||
}
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
ws: null,
|
||||
wsConnected: false,
|
||||
activeTab: 'channel',
|
||||
tabs: [
|
||||
{ id: 'channel', label: 'Inputs 1-40', prefix: 'CH' },
|
||||
{ id: 'mix_sends', label: 'Sends on Faders (Spie)', prefix: 'CH' },
|
||||
{ id: 'steinch', label: 'ST IN 1-2', prefix: 'ST' },
|
||||
{ id: 'fxrtn', label: 'FX Returns', prefix: 'FX' },
|
||||
{ id: 'mix', label: 'Mixes Masters', prefix: 'MIX' },
|
||||
{ id: 'dca', label: 'DCA 1-8', prefix: 'DCA' }
|
||||
],
|
||||
state: { channels: [], steinch: [], mixes: [], dcas: [], fxrtn: [], stereo: -120, stereo_on: false },
|
||||
meterState: {},
|
||||
|
||||
activeMixId: 1,
|
||||
mixSendsData: [],
|
||||
isLoadingSends: false,
|
||||
|
||||
draggingFaders: {}, localLevels: {}, lastSendTime: {}, releaseTimers: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentFaders() {
|
||||
let dataArray = [];
|
||||
if (this.activeTab === 'channel') dataArray = this.state.channels;
|
||||
else if (this.activeTab === 'steinch') dataArray = this.state.steinch;
|
||||
else if (this.activeTab === 'mix') dataArray = this.state.mixes;
|
||||
else if (this.activeTab === 'dca') dataArray = this.state.dcas;
|
||||
else if (this.activeTab === 'fxrtn') dataArray = this.state.fxrtn;
|
||||
else if (this.activeTab === 'mix_sends') dataArray = this.mixSendsData;
|
||||
|
||||
return dataArray.map(item => {
|
||||
const id = item.channel || item.mix || item.dca || item.id;
|
||||
const isDragging = this.draggingFaders[id];
|
||||
const displayDb = isDragging ? this.localLevels[id] : (item.level_db === -Infinity || item.level_db <= -120 ? -120 : item.level_db);
|
||||
return { id: id, name: item.name, on: item.on, level_db: displayDb, slider_pos: dbToSlider(displayDb) };
|
||||
});
|
||||
},
|
||||
currentMixMaster() {
|
||||
if (!this.state.mixes) return null;
|
||||
const mixObj = this.state.mixes.find(m => m.mix == this.activeMixId);
|
||||
if (!mixObj) return null;
|
||||
const id = 'mix-master';
|
||||
const isDragging = this.draggingFaders[id];
|
||||
const displayDb = isDragging ? this.localLevels[id] : (mixObj.level_db <= -120 ? -120 : mixObj.level_db);
|
||||
return { name: mixObj.name || ('Mix ' + this.activeMixId), on: mixObj.on, level_db: displayDb, slider_pos: dbToSlider(displayDb) };
|
||||
},
|
||||
stereoFader() {
|
||||
const isDragging = this.draggingFaders['stereo'];
|
||||
const displayDb = isDragging ? this.localLevels['stereo'] : (this.state.stereo <= -120 ? -120 : this.state.stereo);
|
||||
return { level_db: displayDb, slider_pos: dbToSlider(displayDb) };
|
||||
},
|
||||
},
|
||||
mounted() { this.connectWebSocket(); },
|
||||
methods: {
|
||||
connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
||||
this.ws.onopen = () => { this.wsConnected = true; };
|
||||
this.ws.onclose = () => { this.wsConnected = false; setTimeout(this.connectWebSocket, 3000); };
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "state_update") { this.state = msg.data; }
|
||||
else if (msg.type === "meter_update") { this.meterState = msg.data; }
|
||||
else if (msg.type === "mix_sends_data" && msg.mix === this.activeMixId) { this.mixSendsData = msg.data; this.isLoadingSends = false; }
|
||||
};
|
||||
},
|
||||
getMeterHeight(id, overrideType = null) {
|
||||
const type = overrideType || (this.activeTab === 'mix_sends' ? 'channel' : this.activeTab);
|
||||
if (!this.meterState[type] || !this.meterState[type].readings) return 0;
|
||||
const meter = this.meterState[type].readings.find(m => m.channel == id);
|
||||
if (!meter || meter.raw === null) return 0;
|
||||
return (meter.raw / 127) * 100;
|
||||
},
|
||||
getFaderLabel(id) {
|
||||
const tabInfo = this.tabs.find(t => t.id === this.activeTab || (this.activeTab === 'mix_sends' && t.id === 'channel'));
|
||||
return `${tabInfo.prefix || 'CH'} ${id}`;
|
||||
},
|
||||
loadMixSends(mixNumber) {
|
||||
this.activeMixId = mixNumber;
|
||||
this.mixSendsData = [];
|
||||
this.isLoadingSends = true;
|
||||
if (this.wsConnected) {
|
||||
this.ws.send(JSON.stringify({ action: 'get_mix_sends', mix: mixNumber }));
|
||||
}
|
||||
},
|
||||
formatDb(val) { return val <= -120 ? "-inf dB" : (val > 0 ? "+" : "") + parseFloat(val).toFixed(1) + " dB"; },
|
||||
startDrag(id) {
|
||||
if (this.releaseTimers[id]) clearTimeout(this.releaseTimers[id]);
|
||||
this.draggingFaders[id] = true;
|
||||
},
|
||||
stopDrag(id) {
|
||||
if (this.draggingFaders[id]) {
|
||||
this.sendFinalLevel(id);
|
||||
this.releaseTimers[id] = setTimeout(() => { this.draggingFaders[id] = false; }, 600);
|
||||
}
|
||||
},
|
||||
handleSliderMove(id, sliderValue, overrideType = null) {
|
||||
const realDb = sliderToDb(sliderValue);
|
||||
this.localLevels[id] = realDb;
|
||||
const now = Date.now();
|
||||
if (!this.lastSendTime[id] || now - this.lastSendTime[id] > 50) {
|
||||
this.sendCommand(id, realDb, overrideType);
|
||||
this.lastSendTime[id] = now;
|
||||
}
|
||||
},
|
||||
sendFinalLevel(id, overrideType = null) {
|
||||
if (this.localLevels[id] !== undefined) this.sendCommand(id, this.localLevels[id], overrideType);
|
||||
},
|
||||
sendCommand(id, dbValue, overrideType = null) {
|
||||
if (!this.wsConnected) return;
|
||||
if (this.activeTab === 'mix_sends' && !overrideType) {
|
||||
const fader = this.mixSendsData.find(f => f.id === id);
|
||||
if (fader) fader.level_db = dbValue;
|
||||
this.ws.send(JSON.stringify({ action: 'set_send_level', id: id, mix: this.activeMixId, value: dbValue }));
|
||||
} else {
|
||||
const type = overrideType || this.activeTab;
|
||||
this.ws.send(JSON.stringify({ action: 'set_level', type: type, id: id, value: dbValue }));
|
||||
}
|
||||
},
|
||||
toggleOn(id, currentState, overrideType = null) {
|
||||
if (!this.wsConnected) return;
|
||||
const type = overrideType || this.activeTab;
|
||||
if (type === 'mix_sends') {
|
||||
const fader = this.mixSendsData.find(f => f.id === id);
|
||||
if (fader) fader.on = !currentState;
|
||||
this.ws.send(JSON.stringify({ action: 'set_send_on', id: id, mix: this.activeMixId, value: !currentState }));
|
||||
} else {
|
||||
this.ws.send(JSON.stringify({ action: 'set_on', type: type, id: id, value: !currentState }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user