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