new analyzer

This commit is contained in:
Overside srl committed 2026-11-04 14:13:04 +01:00
1 parent 4b1fc0835a
commit 98d9fa24cf
110 files changed
+1749 -52

No files matched your search

+187 -16
View File
@@ -7,9 +7,18 @@ signal volume_updated(rms_value: float)
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
@@ -21,8 +30,23 @@ 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:
@@ -35,27 +59,97 @@ func _init_native_wasapi_loopback() -> void:
_udp_send.set_dest_address("127.0.0.1", cmd_port)
if err == OK:
var exe_path = ProjectSettings.globalize_path("res://bin/LoopbackCapture.exe")
if FileAccess.file_exists("res://bin/LoopbackCapture.exe"):
_loopback_pid = OS.create_process(exe_path, [str(udp_port)], false)
_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:
# Watchdog: se LoopbackCapture non è in esecuzione, riavvialo automaticamente
_watchdog_timer += delta
if _watchdog_timer >= 2.0:
_watchdog_timer = 0.0
if _loopback_pid <= 0 or not OS.is_process_running(_loopback_pid):
var exe_path = ProjectSettings.globalize_path("res://bin/LoopbackCapture.exe")
if FileAccess.file_exists("res://bin/LoopbackCapture.exe"):
_loopback_pid = OS.create_process(exe_path, [str(udp_port)], false)
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
@@ -67,9 +161,35 @@ func _process(delta: float) -> void:
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
# Pacchetto audio standard: 8 byte (4 byte RMS + 4 byte BPM)
if packet.size() >= 4:
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
@@ -82,6 +202,16 @@ func _process(delta: float) -> void:
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:
@@ -89,6 +219,47 @@ func _process(delta: float) -> void:
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(";")
@@ -97,8 +268,8 @@ func _parse_applications_list(raw_text: String) -> void:
var parts = token.split(":")
if parts.size() >= 2:
var pid = int(parts[0])
var name = parts[1]
apps.append({"pid": pid, "name": name})
var curr_name = parts[1]
apps.append({"pid": pid, "name": curr_name})
if not apps.is_empty():
applications_updated.emit(apps)