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()