web console

This commit is contained in:
Nick
2026-04-13 20:57:13 +02:00
parent deee299af5
commit 84c1f0da35
9 changed files with 1023 additions and 357 deletions
+88 -29
View File
@@ -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.
+1
View File
@@ -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")
File diff suppressed because one or more lines are too long
+249
View File
@@ -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
View File
@@ -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 0127.
# Conversione: 0 = -inf dB, 1127 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)
+389
View File
@@ -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">&nbsp;</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>