new analyzer
This commit is contained in:
1 parent
4b1fc0835a
commit
98d9fa24cf
110 files changed
+1749
-52
No files matched your search
@@ -27,6 +27,8 @@ enum State {
|
||||
var current_state: State = State.IDLE
|
||||
var _silence_timer: float = 0.0
|
||||
var _current_rms: float = 0.0
|
||||
var _current_genre: String = ""
|
||||
var _current_genre_conf: float = 0.0
|
||||
|
||||
# BPM dinamico: parte da 0 finché la canzone non viene agganciata
|
||||
var _current_bpm: float = 0.0
|
||||
@@ -48,6 +50,7 @@ func _ready() -> void:
|
||||
audio_analyzer.volume_updated.connect(_on_volume_updated)
|
||||
audio_analyzer.bpm_updated.connect(_on_bpm_updated)
|
||||
audio_analyzer.applications_updated.connect(_on_applications_updated)
|
||||
audio_analyzer.genre_updated.connect(_on_genre_updated)
|
||||
|
||||
if app_selector:
|
||||
app_selector.item_selected.connect(_on_app_selected)
|
||||
@@ -56,18 +59,22 @@ func _ready() -> void:
|
||||
|
||||
_set_state(State.IDLE)
|
||||
|
||||
func _on_genre_updated(genre: String, confidence: float, _probs: Dictionary) -> void:
|
||||
_current_genre = genre
|
||||
_current_genre_conf = confidence
|
||||
|
||||
func _on_applications_updated(apps: Array[Dictionary]) -> void:
|
||||
if not app_selector:
|
||||
return
|
||||
|
||||
# Evita di ricreare la lista se gli elementi sono identici (previene glitch della UI)
|
||||
var is_same = (apps.size() == _known_apps.size())
|
||||
if is_same:
|
||||
var issame = (apps.size() == _known_apps.size())
|
||||
if issame:
|
||||
for i in range(apps.size()):
|
||||
if apps[i]["pid"] != _known_apps[i]["pid"] or apps[i]["name"] != _known_apps[i]["name"]:
|
||||
is_same = false
|
||||
issame = false
|
||||
break
|
||||
if is_same:
|
||||
if issame:
|
||||
return
|
||||
|
||||
_known_apps = apps
|
||||
@@ -207,8 +214,12 @@ func _update_debug_ui() -> void:
|
||||
if _current_bpm > 50.0:
|
||||
bpm_str = "%d BPM" % round(_current_bpm)
|
||||
|
||||
debug_label.text = "Stato: %s | Volume: %.2f | %s" % [
|
||||
var genre_str: String = "Genere: --"
|
||||
if not _current_genre.is_empty() and _current_genre != "unknown":
|
||||
genre_str = "%s (%d%%)" % [_current_genre, roundi(_current_genre_conf * 100.0)]
|
||||
debug_label.text = "Stato: %s | Volume: %.2f | %s | %s" % [
|
||||
State.keys()[current_state],
|
||||
_current_rms,
|
||||
bpm_str
|
||||
bpm_str,
|
||||
genre_str
|
||||
]
|
||||
+187
-16
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user