Files
lddc/scripts/SystemAudioAnalyzer.gd
T
2026-11-04 14:13:04 +01:00

283 lines
10 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)
@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
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
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
_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
# 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)
# 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
# Version, raw label and raw confidence precede the five probabilities.
# The displayed label/confidence are recomputed from the smoothed distribution.
var incoming: Dictionary = {}
var total: float = 0.0
for i in range(3, fields.size()):
var pair: PackedStringArray = fields[i].split("=")
if pair.size() != 2 or 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
var first: bool = _genre_probs.is_empty()
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"
var now: int = Time.get_ticks_msec()
if best != _genre_argmax or now - _genre_last_emit_msec >= 5000:
_genre_argmax = best
_genre_last_emit_msec = now
print("[SystemAudioAnalyzer] Genere: %s (%.0f%%)" % [label, confidence * 100.0])
genre_updated.emit(label, confidence, _genre_probs.duplicate())
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()