359 lines
13 KiB
GDScript
359 lines
13 KiB
GDScript
class_name SystemAudioAnalyzer
|
|
extends Node
|
|
|
|
## Emette il valore RMS continuo [0.0 - 1.0] esclusivo dell'audio di sistema/applicazione
|
|
signal volume_updated(rms_value: float)
|
|
## Emette il BPM rilevato in tempo reale dalla musica
|
|
signal bpm_updated(bpm: float)
|
|
## Emette la lista delle applicazioni audio attive (es. Spotify, Chrome, Discord)
|
|
signal applications_updated(apps: Array[Dictionary])
|
|
signal genre_updated(genre: String, confidence: float, probs: Dictionary, source: String, detail: String)
|
|
|
|
@export var udp_port: int = 4242
|
|
@export var cmd_port: int = 4243
|
|
@export var heartbeat_timeout_ms: int = 1500
|
|
@export var lease_enabled: bool = true
|
|
@export var genre_enabled: bool = true
|
|
@export var diag_enabled: bool = true
|
|
|
|
const LEASE_MS: int = 10000
|
|
const GENRE_LABELS = ["latin", "electronic", "hiphop", "rock", "classical"]
|
|
const GENRE_ALPHA: float = 0.25
|
|
const GENRE_MIN_CONFIDENCE: float = 0.30
|
|
|
|
@export_group("Gain & Sensitivity")
|
|
@export var system_audio_gain: float = 3.8
|
|
@export var system_noise_floor: float = 0.0003
|
|
|
|
var _udp_peer: PacketPeerUDP
|
|
var _udp_send: PacketPeerUDP
|
|
var _loopback_pid: int = -1
|
|
var _loopback_rms: float = 0.0
|
|
var _last_bpm: float = 0.0
|
|
var _watchdog_timer: float = 0.0
|
|
var _last_heartbeat_msec: int = 0
|
|
var _bridge_protocol: int = 0
|
|
var _restart_delay: float = 1.0
|
|
var _next_restart_at: int = 0
|
|
var _selected_pid: int = 0
|
|
var _restore_selection_at: int = -1
|
|
var _healthy_since: int = -1
|
|
var _last_health_packet: int = -1
|
|
var _restart_attempt: bool = false
|
|
var _bridge_enabled: bool = false
|
|
var _last_ping_msec: int = 0
|
|
var _genre_probs: Dictionary = {}
|
|
var _genre_argmax: String = ""
|
|
var _genre_last_emit_msec: int = -5000
|
|
var _genre_source: String = ""
|
|
var _genre_last_packet_msec: int = -1
|
|
var _diag_last_status: String = ""
|
|
|
|
func _ready() -> void:
|
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
_init_native_wasapi_loopback()
|
|
|
|
func _init_native_wasapi_loopback() -> void:
|
|
_udp_peer = PacketPeerUDP.new()
|
|
var err = _udp_peer.bind(udp_port, "*")
|
|
if err != OK:
|
|
err = _udp_peer.bind(udp_port, "127.0.0.1")
|
|
|
|
_udp_send = PacketPeerUDP.new()
|
|
_udp_send.set_dest_address("127.0.0.1", cmd_port)
|
|
|
|
if err == OK:
|
|
_bridge_enabled = true
|
|
_spawn_bridge()
|
|
|
|
func _spawn_bridge() -> void:
|
|
var now: int = Time.get_ticks_msec()
|
|
if now < _next_restart_at:
|
|
return
|
|
# Discard queued packets from the preceding session without closing the socket.
|
|
while _udp_peer.get_available_packet_count() > 0:
|
|
_udp_peer.get_packet()
|
|
_bridge_protocol = 0
|
|
if _genre_source == "metadata":
|
|
genre_updated.emit("unknown", 0.0, {}, "rules", "")
|
|
_genre_source = ""
|
|
_genre_last_packet_msec = -1
|
|
_genre_probs.clear()
|
|
_genre_argmax = ""
|
|
_genre_last_emit_msec = -5000
|
|
_last_heartbeat_msec = now
|
|
_healthy_since = -1
|
|
_last_health_packet = -1
|
|
var exe_path = ProjectSettings.globalize_path("res://bin/LoopbackCapture.exe")
|
|
if FileAccess.file_exists("res://bin/LoopbackCapture.exe"):
|
|
var args: PackedStringArray = [str(udp_port), str(OS.get_process_id())]
|
|
if lease_enabled and not OS.is_debug_build():
|
|
args.append("--lease-ms=%d" % LEASE_MS)
|
|
_loopback_pid = OS.create_process(exe_path, args, false)
|
|
else:
|
|
_loopback_pid = -1
|
|
if _loopback_pid <= 0:
|
|
_schedule_restart("avvio fallito")
|
|
else:
|
|
_restore_selection_at = now + 300
|
|
_send_ping()
|
|
|
|
func _send_ping() -> void:
|
|
if _udp_send and _loopback_pid > 0 and OS.is_process_running(_loopback_pid):
|
|
_last_ping_msec = Time.get_ticks_msec()
|
|
_udp_send.put_packet(("PING:%d" % _last_ping_msec).to_utf8_buffer())
|
|
|
|
func _schedule_restart(reason: String, exit_code: int = -1) -> void:
|
|
if _restart_attempt:
|
|
_restart_delay = minf(_restart_delay * 2.0, 30.0)
|
|
if exit_code == 3:
|
|
print("[SystemAudioAnalyzer] istanza gia attiva, attendo")
|
|
_restart_delay = 30.0
|
|
# Limite noto: un'istanza vecchia senza heartbeat non viene terminata.
|
|
print("[SystemAudioAnalyzer] Riavvio: %s; attesa %.1f s" % [reason, _restart_delay])
|
|
_next_restart_at = Time.get_ticks_msec() + int(_restart_delay * 1000.0)
|
|
_restart_attempt = true
|
|
_loopback_pid = -1
|
|
_restore_selection_at = -1
|
|
_healthy_since = -1
|
|
|
|
func _record_health_packet(now: int) -> void:
|
|
if _loopback_pid <= 0:
|
|
return
|
|
if _last_health_packet < 0 or now - _last_health_packet > heartbeat_timeout_ms:
|
|
_healthy_since = now
|
|
_last_health_packet = now
|
|
if now - _healthy_since >= 10000:
|
|
_restart_delay = 1.0
|
|
_restart_attempt = false
|
|
|
|
func _check_bridge_lifecycle() -> void:
|
|
if not _bridge_enabled:
|
|
return
|
|
var now: int = Time.get_ticks_msec()
|
|
if _loopback_pid <= 0:
|
|
if now >= _next_restart_at:
|
|
_spawn_bridge()
|
|
return
|
|
if not OS.is_process_running(_loopback_pid):
|
|
_schedule_restart("processo terminato", OS.get_process_exit_code(_loopback_pid))
|
|
elif _bridge_protocol > 0:
|
|
# Finche' non arriva il primo HB l'exe puo' essere ancora in inizializzazione
|
|
# WASAPI: concedi una finestra piu' ampia per evitare falsi timeout a freddo.
|
|
var timeout: int = heartbeat_timeout_ms
|
|
if _last_health_packet < 0:
|
|
timeout = maxi(heartbeat_timeout_ms, 5000)
|
|
if now - _last_heartbeat_msec > timeout:
|
|
OS.kill(_loopback_pid)
|
|
_schedule_restart("timeout heartbeat")
|
|
|
|
## Invia al bridge la richiesta di ascoltare SOLO l'applicazione con il PID specificato (0 = Tutto)
|
|
func select_application_pid(pid: int) -> void:
|
|
_selected_pid = pid
|
|
if _udp_send:
|
|
var msg = "PID:%d" % pid
|
|
_udp_send.put_packet(msg.to_utf8_buffer())
|
|
print("[SystemAudioAnalyzer] Applicazione audio selezionata PID: ", pid)
|
|
|
|
func _process(delta: float) -> void:
|
|
if Time.get_ticks_msec() - _last_ping_msec >= 2000:
|
|
_send_ping()
|
|
# Lettura esclusiva dell'Audio di Sistema o dell'Applicazione selezionata (Zero Microfono)
|
|
if _udp_peer:
|
|
var packet_received: bool = false
|
|
while _udp_peer.get_available_packet_count() > 0:
|
|
var packet: PackedByteArray = _udp_peer.get_packet()
|
|
|
|
# Pacchetto con la lista delle applicazioni attive (APPS:...)
|
|
if packet.size() > 5 and packet[0] == 65 and packet[1] == 80 and packet[2] == 80 and packet[3] == 83 and packet[4] == 58:
|
|
var text: String = packet.get_string_from_utf8()
|
|
_parse_applications_list(text)
|
|
continue
|
|
if packet.size() >= 3 and packet[0] == 72 and packet[1] == 66 and packet[2] == 58:
|
|
_last_heartbeat_msec = Time.get_ticks_msec()
|
|
_record_health_packet(_last_heartbeat_msec)
|
|
continue
|
|
if packet.size() >= 6 and packet[0] == 72 and packet[1] == 69 and packet[2] == 76 and packet[3] == 76 and packet[4] == 79 and packet[5] == 58:
|
|
if _bridge_protocol == 0 and _loopback_pid > 0:
|
|
_bridge_protocol = packet.get_string_from_utf8().substr(6).to_int()
|
|
_last_heartbeat_msec = Time.get_ticks_msec()
|
|
_healthy_since = -1
|
|
_last_health_packet = -1
|
|
select_application_pid(_selected_pid)
|
|
# Retain the 300ms resend for legacy bridges without command readiness.
|
|
continue
|
|
|
|
if packet.size() >= 5 and packet[0] == 71 and packet[1] == 69 and packet[2] == 78 and packet[3] == 82 and packet[4] == 58:
|
|
if genre_enabled:
|
|
_parse_genre(packet.get_string_from_utf8())
|
|
continue
|
|
|
|
# Pacchetto diagnostico per-sorgente (DIAG:...). Riconosciuto PRIMA del ramo
|
|
# binario e ignorato ai fini del genere.
|
|
if packet.size() >= 5 and packet[0] == 68 and packet[1] == 73 and packet[2] == 65 and packet[3] == 71 and packet[4] == 58:
|
|
_parse_diag(packet.get_string_from_utf8())
|
|
continue
|
|
|
|
# Convenzione: le future estensioni testuali non devono MAI essere lunghe
|
|
# esattamente 8 byte (messaggio completo, non solo prefisso).
|
|
# HELLO:/HB:/APPS:/GENR: sono riconosciuti sopra; PING: e i futuri STATUS:/SILENT:
|
|
# devono rispettare la convenzione o essere gestiti prima del ramo binario.
|
|
# Nessuna euristica sui byte: anche 0x41 e' legittimo in un float little-endian.
|
|
# Pacchetto audio standard: 8 byte (4 byte RMS + 4 byte BPM).
|
|
if packet.size() == 8:
|
|
# No HELLO means legacy mode: process liveness only, no HB timeout.
|
|
if _bridge_protocol == 0:
|
|
_record_health_packet(Time.get_ticks_msec())
|
|
_loopback_rms = packet.decode_float(0)
|
|
packet_received = true
|
|
|
|
if packet.size() >= 8:
|
|
var incoming_bpm: float = packet.decode_float(4)
|
|
if incoming_bpm > 50.0:
|
|
_last_bpm = incoming_bpm
|
|
bpm_updated.emit(incoming_bpm)
|
|
|
|
if not packet_received:
|
|
_loopback_rms = move_toward(_loopback_rms, 0.0, delta * 3.0)
|
|
|
|
# A stalled metadata stream must not leave a permanent 100% label on screen.
|
|
if _genre_source == "metadata" and _genre_last_packet_msec >= 0 and Time.get_ticks_msec() - _genre_last_packet_msec > 10000:
|
|
_genre_probs.clear()
|
|
_genre_argmax = ""
|
|
_genre_source = "rules"
|
|
_genre_last_packet_msec = -1
|
|
genre_updated.emit("unknown", 0.0, {}, "rules", "")
|
|
|
|
# Read queued heartbeats before checking the watchdog to avoid false timeouts.
|
|
_watchdog_timer += delta
|
|
if _watchdog_timer >= 0.5:
|
|
_watchdog_timer = 0.0
|
|
_check_bridge_lifecycle()
|
|
if _restore_selection_at >= 0 and Time.get_ticks_msec() >= _restore_selection_at:
|
|
_restore_selection_at = -1
|
|
if _loopback_pid > 0 and OS.is_process_running(_loopback_pid):
|
|
select_application_pid(_selected_pid)
|
|
|
|
# Calcolo Volume Percepito (Curva Loudness)
|
|
var processed_rms: float = 0.0
|
|
if _loopback_rms > system_noise_floor:
|
|
processed_rms = clampf(sqrt(_loopback_rms) * system_audio_gain, 0.0, 1.0)
|
|
|
|
volume_updated.emit(processed_rms)
|
|
|
|
func _parse_genre(raw_text: String) -> void:
|
|
var fields: PackedStringArray = raw_text.substr(5).split(";")
|
|
if fields.size() < 8:
|
|
return
|
|
var source: String = "rules"
|
|
var first_probability: int = 3
|
|
if fields[0] == "2":
|
|
if fields.size() < 9 or not ["metadata", "onnx", "rules"].has(fields[3]):
|
|
return
|
|
source = fields[3]
|
|
first_probability = 4
|
|
elif fields[0] != "1":
|
|
return
|
|
if source == "metadata" and (not GENRE_LABELS.has(fields[1]) or not fields[2].is_valid_float() or fields[2].to_float() != 1.0):
|
|
return
|
|
var incoming: Dictionary = {}
|
|
var total: float = 0.0
|
|
var detail: String = ""
|
|
for i in range(first_probability, fields.size()):
|
|
# maxsplit=1: un valore puo' contenere '=' senza rompere il parsing.
|
|
var pair: PackedStringArray = fields[i].split("=", true, 1)
|
|
if pair.size() != 2:
|
|
continue
|
|
# Genere specifico dal catalogo ("Hard Rock", "Hip-Hop/Rap"): solo descrittivo,
|
|
# non partecipa alle probabilita' e non entra nella media esponenziale.
|
|
if pair[0] == "detail":
|
|
detail = pair[1].strip_edges()
|
|
continue
|
|
if not GENRE_LABELS.has(pair[0]):
|
|
continue
|
|
if incoming.has(pair[0]) or not pair[1].is_valid_float():
|
|
return
|
|
var probability: float = pair[1].to_float()
|
|
if not is_finite(probability) or probability < 0.0 or probability > 1.0:
|
|
return
|
|
incoming[pair[0]] = probability
|
|
total += probability
|
|
if incoming.size() != GENRE_LABELS.size() or total <= 0.0:
|
|
return
|
|
if source == "metadata":
|
|
# Discrete lookup: require a coherent one-hot distribution, never EMA-blend it.
|
|
for genre in GENRE_LABELS:
|
|
if float(incoming[genre]) != (1.0 if genre == fields[1] else 0.0):
|
|
return
|
|
var source_changed: bool = source != _genre_source
|
|
var first: bool = _genre_probs.is_empty() or source_changed or source == "metadata"
|
|
var best: String = ""
|
|
var confidence: float = -1.0
|
|
for genre in GENRE_LABELS:
|
|
var probability: float = float(incoming[genre]) / total
|
|
if first:
|
|
_genre_probs[genre] = probability
|
|
else:
|
|
_genre_probs[genre] = lerpf(float(_genre_probs[genre]), probability, GENRE_ALPHA)
|
|
if float(_genre_probs[genre]) > confidence:
|
|
confidence = float(_genre_probs[genre])
|
|
best = genre
|
|
var label: String = best if confidence >= GENRE_MIN_CONFIDENCE else "unknown"
|
|
if source != "metadata":
|
|
detail = ""
|
|
var now: int = Time.get_ticks_msec()
|
|
_genre_last_packet_msec = now
|
|
_genre_source = source
|
|
if source_changed or best != _genre_argmax or now - _genre_last_emit_msec >= 5000:
|
|
_genre_argmax = best
|
|
_genre_last_emit_msec = now
|
|
var shown: String = detail if not detail.is_empty() else label
|
|
print("[SystemAudioAnalyzer] Genere: %s (%.0f%%) [%s]" % [shown, confidence * 100.0, source])
|
|
genre_updated.emit(label, confidence, _genre_probs.duplicate(), source, detail)
|
|
|
|
## Stampa una riga compatta dello stato per-sorgente solo quando uno dei campi di
|
|
## stato cambia (non a ogni pacchetto). Il contenuto DIAG non influenza il genere.
|
|
func _parse_diag(raw_text: String) -> void:
|
|
if not diag_enabled:
|
|
return
|
|
var fields: PackedStringArray = raw_text.substr(5).split(";")
|
|
if fields.size() < 2:
|
|
return
|
|
var diag: Dictionary = {}
|
|
for i in range(1, fields.size()):
|
|
var pair: PackedStringArray = fields[i].split("=", true, 1)
|
|
if pair.size() != 2:
|
|
continue
|
|
diag[pair[0]] = pair[1]
|
|
var meta: String = diag.get("meta", "")
|
|
var onnx: String = diag.get("onnx", "")
|
|
var itunes: String = diag.get("itunes", "")
|
|
var deezer: String = diag.get("deezer", "")
|
|
var bpm: String = diag.get("bpm", "0.0")
|
|
var anchor: String = diag.get("anchor", "0.0")
|
|
var status_key: String = "%s|%s|%s|%s" % [meta, onnx, itunes, deezer]
|
|
if status_key != _diag_last_status:
|
|
_diag_last_status = status_key
|
|
print("[DIAG] meta=%s onnx=%s itunes=%s deezer=%s bpm=%s anchor=%s" % [meta, onnx, itunes, deezer, bpm, anchor])
|
|
|
|
func _parse_applications_list(raw_text: String) -> void:
|
|
var payload = raw_text.substr(5)
|
|
var tokens = payload.split(";")
|
|
var apps: Array[Dictionary] = []
|
|
for token in tokens:
|
|
var parts = token.split(":")
|
|
if parts.size() >= 2:
|
|
var pid = int(parts[0])
|
|
var curr_name = parts[1]
|
|
apps.append({"pid": pid, "name": curr_name})
|
|
if not apps.is_empty():
|
|
applications_updated.emit(apps)
|
|
|
|
func _exit_tree() -> void:
|
|
if _loopback_pid > 0:
|
|
OS.kill(_loopback_pid)
|
|
if _udp_peer:
|
|
_udp_peer.close()
|
|
if _udp_send:
|
|
_udp_send.close()
|