commit 4b1fc0835a1597e2add6468c7ccfd7a2687c6a57 Author: m.cirafisi Date: Tue Nov 3 23:07:33 2026 +0100 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60de3e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Godot 4+ specific ignores +.godot/ +/android/ + +# Build & temp logs +*.log diff --git a/bin/LoopbackCapture.cs b/bin/LoopbackCapture.cs new file mode 100644 index 0000000..cbe0dac --- /dev/null +++ b/bin/LoopbackCapture.cs @@ -0,0 +1,606 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace LoopbackCapture +{ + class Program + { + const int SIO_UDP_CONNRESET = -1744830452; + + static int selectedTargetPid = 0; // 0 = Tutto il sistema + + static void Main(string[] args) + { + int port = 4242; + int cmdPort = 4243; + if (args.Length > 0) + { + int.TryParse(args[0], out port); + cmdPort = port + 1; + } + + UdpClient udpSend = null; + try + { + udpSend = new UdpClient(); + byte[] inValue = new byte[] { 0, 0, 0, 0 }; + byte[] outValue = new byte[] { 0, 0, 0, 0 }; + udpSend.Client.IOControl(SIO_UDP_CONNRESET, inValue, outValue); + } + catch { } + + IPEndPoint remoteEp = new IPEndPoint(IPAddress.Loopback, port); + + // Listener comandi di selezione processo inviati da Godot + Thread cmdListenerThread = new Thread(() => + { + UdpClient udpCmd = null; + try + { + udpCmd = new UdpClient(cmdPort); + IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0); + while (true) + { + byte[] bytes = udpCmd.Receive(ref sender); + string cmd = Encoding.UTF8.GetString(bytes).Trim(); + if (cmd.StartsWith("PID:")) + { + int pid; + if (int.TryParse(cmd.Substring(4), out pid)) + { + selectedTargetPid = pid; + } + } + } + } + catch { } + }); + cmdListenerThread.IsBackground = true; + cmdListenerThread.Start(); + + IMMDeviceEnumerator enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); + IMMDevice device = null; + int hr = enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out device); + if (hr != 0 || device == null) + { + hr = enumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eConsole, out device); + if (hr != 0 || device == null) return; + } + + Guid iidMgr = typeof(IAudioSessionManager2).GUID; + object oMgr; + device.Activate(ref iidMgr, 1, IntPtr.Zero, out oMgr); + IAudioSessionManager2 sessionManager = (IAudioSessionManager2)oMgr; + + Guid iidAudioClient = typeof(IAudioClient).GUID; + object oAudioClient; + hr = device.Activate(ref iidAudioClient, 1, IntPtr.Zero, out oAudioClient); + if (hr != 0 || oAudioClient == null) return; + + IAudioClient audioClient = (IAudioClient)oAudioClient; + IntPtr pMixFormat; + hr = audioClient.GetMixFormat(out pMixFormat); + if (hr != 0 || pMixFormat == IntPtr.Zero) return; + + WAVEFORMATEX wf = (WAVEFORMATEX)Marshal.PtrToStructure(pMixFormat, typeof(WAVEFORMATEX)); + int channels = wf.nChannels; + int bitsPerSample = wf.wBitsPerSample; + int sampleRate = wf.nSamplesPerSec > 0 ? wf.nSamplesPerSec : 48000; + bool isFloat = (wf.wFormatTag == 3); + + if (wf.wFormatTag == -2) + { + WAVEFORMATEXTENSIBLE wfe = (WAVEFORMATEXTENSIBLE)Marshal.PtrToStructure(pMixFormat, typeof(WAVEFORMATEXTENSIBLE)); + if (wfe.SubFormat == new Guid("00000003-0000-0010-8000-00aa00389b71")) isFloat = true; + } + + if (bitsPerSample == 32 && !isFloat) + { + byte[] raw = new byte[40]; + Marshal.Copy(pMixFormat, raw, 0, 40); + if (raw[24] == 3) isFloat = true; + } + + const int AUDCLNT_STREAMFLAGS_LOOPBACK = 0x00020000; + const int AUDCLNT_SHAREMODE_SHARED = 0; + Guid emptySession = Guid.Empty; + + hr = audioClient.Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, 10000000, 0, pMixFormat, ref emptySession); + if (hr != 0) return; + + Guid iidCaptureClient = typeof(IAudioCaptureClient).GUID; + object oCaptureClient; + hr = audioClient.GetService(ref iidCaptureClient, out oCaptureClient); + if (hr != 0 || oCaptureClient == null) return; + + IAudioCaptureClient captureClient = (IAudioCaptureClient)oCaptureClient; + audioClient.Start(); + + // Filtro Passa-Basso sui Sub-Bassi (cassa e basse frequenze: 120 Hz) + float cutoffFreq = 120.0f; + float alpha = (float)(2.0 * Math.PI * cutoffFreq / sampleRate); + alpha = Math.Max(0.003f, Math.Min(0.08f, alpha)); + float lp1 = 0f; + float lp2 = 0f; + + float currentTotalRms = 0f; + + // Buffer circolare onset flux temporizzato ai campioni audio reali (100 Hz = ogni 10ms esatti) + int hopSize = sampleRate / 100; + if (hopSize < 1) hopSize = 480; + const int BUFFER_HOPS = 400; // 4.0 secondi di finestra temporale + float[] onsetBuffer = new float[BUFFER_HOPS]; + int onsetHead = 0; + int totalHopsRecorded = 0; + + int hopFrameAccumulator = 0; + double hopKickSumSq = 0; + double hopTotalSumSq = 0; + float prevHopKickRms = 0; + float prevHopTotalRms = 0; + + float detectedBpm = 0.0f; + int candidateBpm = 0; + int candidateStreak = 0; + int bpmEvalCounter = 0; + + long lastAppListBroadcast = 0; + int myPid = Process.GetCurrentProcess().Id; + + while (true) + { + // 1. Lettura campioni PCM WASAPI dal loopback di sistema + uint packetLength = 0; + hr = captureClient.GetNextPacketSize(out packetLength); + + while (packetLength > 0 && hr == 0) + { + IntPtr pData; + uint numFramesRead; + int flags; + ulong devPos, qpcPos; + + hr = captureClient.GetBuffer(out pData, out numFramesRead, out flags, out devPos, out qpcPos); + if (hr != 0) break; + + if ((flags & 1) == 0 && numFramesRead > 0) + { + double totalSumSq = 0; + double kickSumSq = 0; + int frames = (int)numFramesRead; + + unsafe + { + byte* ptr = (byte*)pData.ToPointer(); + for (int f = 0; f < frames; f++) + { + float monoSample = 0f; + if (isFloat && bitsPerSample == 32) + { + float* fptr = (float*)ptr + (f * channels); + for (int c = 0; c < channels; c++) monoSample += fptr[c]; + monoSample /= channels; + } + else if (!isFloat && bitsPerSample == 32) + { + int* iptr = (int*)ptr + (f * channels); + for (int c = 0; c < channels; c++) monoSample += (iptr[c] / 2147483648.0f); + monoSample /= channels; + } + else if (bitsPerSample == 16) + { + short* sptr = (short*)ptr + (f * channels); + for (int c = 0; c < channels; c++) monoSample += (sptr[c] / 32768.0f); + monoSample /= channels; + } + + totalSumSq += (monoSample * monoSample); + + // Filtro IIR passa-basso a doppio polo per estrarre la cassa + lp1 += alpha * (monoSample - lp1); + lp2 += alpha * (lp1 - lp2); + kickSumSq += (lp2 * lp2); + + hopTotalSumSq += (monoSample * monoSample); + hopKickSumSq += (lp2 * lp2); + hopFrameAccumulator++; + + // Ogni 10ms esatti di campioni audio registrati dal clock DAC + if (hopFrameAccumulator >= hopSize) + { + float hopKickRms = (float)Math.Sqrt(hopKickSumSq / hopSize); + float hopTotalRms = (float)Math.Sqrt(hopTotalSumSq / hopSize); + + float kickFlux = Math.Max(0f, hopKickRms - prevHopKickRms); + float totalFlux = Math.Max(0f, hopTotalRms - prevHopTotalRms); + prevHopKickRms = hopKickRms; + prevHopTotalRms = hopTotalRms; + + // L'onset combina la spinta del sub-basso (3.5x) e l'impatto a banda intera + float onset = (kickFlux * 3.5f) + totalFlux; + onsetBuffer[onsetHead] = onset; + onsetHead = (onsetHead + 1) % BUFFER_HOPS; + if (totalHopsRecorded < BUFFER_HOPS) totalHopsRecorded++; + + hopFrameAccumulator = 0; + hopKickSumSq = 0; + hopTotalSumSq = 0; + } + } + } + + if (frames > 0) + { + float packetTotalRms = (float)Math.Sqrt(totalSumSq / frames); + currentTotalRms = (currentTotalRms * 0.25f) + (packetTotalRms * 0.75f); + } + } + else + { + currentTotalRms *= 0.7f; + } + + captureClient.ReleaseBuffer(numFramesRead); + hr = captureClient.GetNextPacketSize(out packetLength); + } + + // 2. Volume mirato se selezionata una specifica applicazione (es. Spotify/Chrome) + float displayRms = currentTotalRms; + if (selectedTargetPid > 0 && sessionManager != null) + { + try + { + IAudioSessionEnumerator sessionEnum; + sessionManager.GetSessionEnumerator(out sessionEnum); + int count; + sessionEnum.GetCount(out count); + for (int i = 0; i < count; i++) + { + object oSession; + sessionEnum.GetSession(i, out oSession); + var session2 = (IAudioSessionControl2)oSession; + uint pid; + session2.GetProcessId(out pid); + if (pid == selectedTargetPid) + { + var meter = (IAudioMeterInformation)oSession; + float peak = 0; + meter.GetPeakValue(out peak); + displayRms = peak; + break; + } + } + } + catch { } + } + + // 3. Autocorrelazione & Comb-Filter su spettro ritmico per rilevamento esatto del BPM + bpmEvalCounter++; + if (bpmEvalCounter >= 10) // Ogni ~80-100ms + { + bpmEvalCounter = 0; + if (totalHopsRecorded >= 120) // Almeno 1.2 secondi di audio + { + // Srotola il buffer circolare e trova il valore massimo per normalizzare + float[] env = new float[totalHopsRecorded]; + float maxOnset = 0.0000001f; + for (int i = 0; i < totalHopsRecorded; i++) + { + int idx = (onsetHead - totalHopsRecorded + i + BUFFER_HOPS) % BUFFER_HOPS; + float val = onsetBuffer[idx]; + env[i] = val; + if (val > maxOnset) maxOnset = val; + } + + // Se c'è un segnale ritmico (anche con volume Windows molto basso) + if (maxOnset > 0.00002f) + { + // Normalizzazione: rende l'autocorrelazione totalmente indipendente dal volume di Windows! + for (int i = 0; i < totalHopsRecorded; i++) + { + env[i] /= maxOnset; + } + + double[] scores = new double[225]; + double[] weights = new double[] { 1.0, 0.5, 0.25, 0.125 }; + + // Valuta tutti i possibili BPM da 70 a 215 + for (int bpm = 70; bpm <= 215; bpm++) + { + double lag = (60.0 / bpm) * 100.0; // 100.0 Hz + double score = 0; + for (int h = 1; h <= 4; h++) + { + int l = (int)Math.Round(h * lag); + if (l >= totalHopsRecorded) break; + double weight = weights[h - 1]; + double dot = 0; + int count = 0; + for (int i = l; i < totalHopsRecorded; i++) + { + dot += env[i] * env[i - l]; + count++; + } + if (count > 0) score += weight * (dot / count); + } + scores[bpm] = score; + } + + // Bias di stabilità: leggero bonus al tempo attivo per assorbire micro-fill di 0.2s + if (detectedBpm > 50.0f) + { + for (int bpm = 70; bpm <= 215; bpm++) + { + float diff = Math.Abs(bpm - detectedBpm); + if (diff <= 6.0f) + { + scores[bpm] *= (1.0 + 0.20 * (1.0 - diff / 6.0)); + } + } + } + + int bestCandidateBpm = 70; + double bestCandidateScore = 0; + for (int bpm = 70; bpm <= 215; bpm++) + { + if (scores[bpm] > bestCandidateScore) + { + bestCandidateScore = scores[bpm]; + bestCandidateBpm = bpm; + } + } + + // Risoluzione ottave: distingue brani lenti (90 BPM) da brani veloci (180-190 BPM). + // Raddoppia solo se C'È EFFETTIVA energia percussiva al tempo veloce (rFast/rSlow >= 0.55), + // evitando che i leggeri charleston a 1/8 di un pezzo a 90 BPM forzino il raddoppio a 180! + if (bestCandidateBpm <= 105 && bestCandidateBpm * 2 <= 215) + { + int doubleBpm = bestCandidateBpm * 2; + if (scores[doubleBpm] >= bestCandidateScore * 0.80) + { + int lagSlow = (int)Math.Round((60.0 / bestCandidateBpm) * 100.0); + int lagFast = (int)Math.Round((60.0 / doubleBpm) * 100.0); + double rSlow = 0, rFast = 0; + int cSlow = 0, cFast = 0; + for (int i = lagSlow; i < totalHopsRecorded; i++) { rSlow += env[i] * env[i - lagSlow]; cSlow++; } + for (int i = lagFast; i < totalHopsRecorded; i++) { rFast += env[i] * env[i - lagFast]; cFast++; } + if (cSlow > 0) rSlow /= cSlow; + if (cFast > 0) rFast /= cFast; + + if (rFast >= rSlow * 0.55) + { + bestCandidateBpm = doubleBpm; + } + } + } + + if (bestCandidateScore > 0.0005) + { + if (detectedBpm <= 10.0f) + { + // Primo aggancio immediato + detectedBpm = bestCandidateBpm; + candidateBpm = bestCandidateBpm; + candidateStreak = 0; + } + else + { + float diff = Math.Abs(bestCandidateBpm - detectedBpm); + if (diff <= 4.0f) + { + // Micro-fluttuazione naturale del brano: adatta dolcemente + detectedBpm = (detectedBpm * 0.94f) + (bestCandidateBpm * 0.06f); + candidateStreak = 0; + } + else + { + // Il candidato si discosta significativamente dal tempo attuale. + // Per cambiare tempo (cambio canzone effettivo) deve rimanere stabile e confermato per almeno 10 cicli (~1 secondo)! + if (Math.Abs(bestCandidateBpm - candidateBpm) <= 3) + { + candidateStreak++; + if (candidateStreak >= 10) + { + detectedBpm = (detectedBpm * 0.4f) + (bestCandidateBpm * 0.6f); + if (Math.Abs(detectedBpm - bestCandidateBpm) < 2.0f) + { + detectedBpm = bestCandidateBpm; + candidateStreak = 0; + } + } + } + else + { + candidateBpm = bestCandidateBpm; + candidateStreak = 1; + } + } + } + } + } + } + } + + // 4. Invio lista applicazioni attive (FILTRANDO se stesso e Godot!) + long nowTicks = DateTime.UtcNow.Ticks; + if ((nowTicks - lastAppListBroadcast) >= (TimeSpan.TicksPerMillisecond * 1500) && sessionManager != null) + { + lastAppListBroadcast = nowTicks; + try + { + IAudioSessionEnumerator sessionEnum; + sessionManager.GetSessionEnumerator(out sessionEnum); + int count; + sessionEnum.GetCount(out count); + StringBuilder sb = new StringBuilder("APPS:0:Tutto il sistema"); + + for (int i = 0; i < count; i++) + { + object oSession; + sessionEnum.GetSession(i, out oSession); + var session2 = (IAudioSessionControl2)oSession; + uint pid; + session2.GetProcessId(out pid); + + if (pid > 0 && pid != myPid) + { + string name = ""; + try + { + Process p = Process.GetProcessById((int)pid); + name = p.ProcessName; + string lowerName = name.ToLower(); + // FILTRA: nasconde LoopbackCapture e Godot dal menu! + if (lowerName.Contains("loopbackcapture") || lowerName.Contains("godot")) continue; + } + catch { } + + if (!string.IsNullOrEmpty(name)) + { + sb.AppendFormat(";{0}:{1}", pid, name); + } + } + } + + byte[] appBytes = Encoding.UTF8.GetBytes(sb.ToString()); + udpSend.Send(appBytes, appBytes.Length, remoteEp); + } + catch { } + } + + // 5. Invio dati audio a Godot (8 byte: RMS + BPM) + if (udpSend != null) + { + byte[] payload = new byte[8]; + Array.Copy(BitConverter.GetBytes(displayRms), 0, payload, 0, 4); + Array.Copy(BitConverter.GetBytes(detectedBpm), 0, payload, 4, 4); + try + { + udpSend.Send(payload, payload.Length, remoteEp); + } + catch { } + } + + Thread.Sleep(8); + } + } + } + + [ComImport] + [Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + class MMDeviceEnumeratorComObject { } + + public enum EDataFlow { eRender, eCapture, eAll } + public enum ERole { eConsole, eMultimedia, eCommunications } + + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDeviceEnumerator + { + int EnumAudioEndpoints(EDataFlow dataFlow, int stateMask, out IntPtr devices); + int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice endpoint); + int GetDevice(string id, out IMMDevice device); + int RegisterEndpointNotificationCallback(IntPtr client); + int UnregisterEndpointNotificationCallback(IntPtr client); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IMMDevice + { + int Activate(ref Guid id, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); + int OpenPropertyStore(int stgmAccess, out IntPtr properties); + int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); + int GetState(out int state); + } + + [Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioSessionManager2 + { + int GetAudioSessionControl(IntPtr sessionId, int priority, out IntPtr control); + int GetSimpleAudioVolume(IntPtr sessionId, int priority, out IntPtr volume); + int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum); + } + + [Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioSessionEnumerator + { + int GetCount(out int sessionCount); + int GetSession(int sessionIndex, [MarshalAs(UnmanagedType.IUnknown)] out object session); + } + + [Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioSessionControl2 + { + int GetState(out int state); + int GetDisplayName(out IntPtr name); + int SetDisplayName(string value, IntPtr eventContext); + int GetIconPath(out IntPtr path); + int SetIconPath(string value, IntPtr eventContext); + int GetGroupingParam(out Guid grouping); + int SetGroupingParam(ref Guid grouping, IntPtr eventContext); + int RegisterAudioSessionNotification(IntPtr client); + int UnregisterAudioSessionNotification(IntPtr client); + int GetSessionIdentifier(out IntPtr id); + int GetSessionInstanceIdentifier(out IntPtr id); + int GetProcessId(out uint processId); + int IsSystemSoundsSession(); + int SetDuckingPreference(bool optOut); + } + + [Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioMeterInformation + { + int GetPeakValue(out float peak); + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct WAVEFORMATEX + { + public short wFormatTag; + public short nChannels; + public int nSamplesPerSec; + public int nAvgBytesPerSec; + public short nBlockAlign; + public short wBitsPerSample; + public short cbSize; + } + + [StructLayout(LayoutKind.Sequential, Pack = 1)] + public struct WAVEFORMATEXTENSIBLE + { + public WAVEFORMATEX Format; + public short wValidBitsPerSample; + public int dwChannelMask; + public Guid SubFormat; + } + + [Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioClient + { + int Initialize(int shareMode, int streamFlags, long hnsBufferDuration, long hnsPeriodicity, IntPtr pFormat, ref Guid audioSessionGuid); + int GetBufferSize(out uint bufferFrameCount); + int GetStreamLatency(out long hnsLatency); + int GetCurrentPadding(out uint numPaddingFrames); + int IsFormatSupported(int shareMode, IntPtr pFormat, out IntPtr ppClosestMatch); + int GetMixFormat(out IntPtr ppDeviceFormat); + int GetDevicePeriod(out long hnsDefaultDevicePeriod, out long hnsMinimumDevicePeriod); + int Start(); + int Stop(); + int Reset(); + int SetEventHandle(IntPtr eventHandle); + int GetService(ref Guid interfaceId, [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); + } + + [Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + public interface IAudioCaptureClient + { + int GetBuffer(out IntPtr dataPointer, out uint numFramesToRead, out int bufferFlags, out ulong devicePosition, out ulong qpcPosition); + int ReleaseBuffer(uint numFramesRead); + int GetNextPacketSize(out uint numFramesInNextPacket); + } +} diff --git a/bin/LoopbackCapture.exe b/bin/LoopbackCapture.exe new file mode 100644 index 0000000..3c98635 Binary files /dev/null and b/bin/LoopbackCapture.exe differ diff --git a/default_bus_layout.tres b/default_bus_layout.tres new file mode 100644 index 0000000..904cbe4 --- /dev/null +++ b/default_bus_layout.tres @@ -0,0 +1,14 @@ +[gd_resource type="AudioBusLayout" format=3 uid="uid://de2evqqag5qan"] + +[sub_resource type="AudioEffectCapture" id="AudioEffectCapture_1"] +resource_name = "AudioEffectCapture" + +[resource] +bus/1/name = &"RecordBus" +bus/1/solo = false +bus/1/mute = true +bus/1/bypass_fx = false +bus/1/volume_db = 0.0 +bus/1/send = &"Master" +bus/1/effect/0/effect = SubResource("AudioEffectCapture_1") +bus/1/effect/0/enabled = true diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..76f829c --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2yvovlrnbclm" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..6012f36 --- /dev/null +++ b/project.godot @@ -0,0 +1,37 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="lddc" +run/main_scene="res://scenes/Main.tscn" +config/features=PackedStringArray("4.6", "GL Compatibility") +config/icon="res://icon.svg" + +[audio] + +driver/enable_input=true + +[display] + +window/size/viewport_width=400 +window/size/viewport_height=500 +window/size/always_on_top=true +window/stretch/mode="canvas_items" + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" diff --git a/scenes/Main.tscn b/scenes/Main.tscn new file mode 100644 index 0000000..be5de87 --- /dev/null +++ b/scenes/Main.tscn @@ -0,0 +1,93 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="Script" path="res://scripts/SystemAudioAnalyzer.gd" id="1_audio_analyzer"] +[ext_resource type="Script" path="res://scripts/CompanionController.gd" id="2_companion"] +[ext_resource type="Texture2D" path="res://icon.svg" id="3_icon"] + +[sub_resource type="SpriteFrames" id="SpriteFrames_1"] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": ExtResource("3_icon") +}], +"loop": true, +"name": &"idle", +"speed": 5.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": ExtResource("3_icon") +}], +"loop": true, +"name": &"dance", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": ExtResource("3_icon") +}], +"loop": true, +"name": &"sleep", +"speed": 2.0 +}] + +[node name="Main" type="Node2D"] + +[node name="SystemAudioAnalyzer" type="Node" parent="."] +script = ExtResource("1_audio_analyzer") + +[node name="Companion" type="Node2D" parent="." node_paths=PackedStringArray("audio_analyzer", "sprite", "debug_label", "volume_bar", "app_selector")] +position = Vector2(200, 280) +script = ExtResource("2_companion") +audio_analyzer = NodePath("../SystemAudioAnalyzer") +sprite = NodePath("AnimatedSprite2D") +debug_label = NodePath("../UI/MarginContainer/VBoxContainer/DebugLabel") +volume_bar = NodePath("../UI/MarginContainer/VBoxContainer/VolumeBar") +app_selector = NodePath("../UI/MarginContainer/VBoxContainer/AppSelector") + +[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="Companion"] +scale = Vector2(1.2, 1.2) +sprite_frames = SubResource("SpriteFrames_1") +animation = &"idle" + +[node name="UI" type="CanvasLayer" parent="."] + +[node name="MarginContainer" type="MarginContainer" parent="UI"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 + +[node name="VBoxContainer" type="VBoxContainer" parent="UI/MarginContainer"] +layout_mode = 2 +size_flags_vertical = 0 +theme_override_constants/separation = 6 + +[node name="TitleLabel" type="Label" parent="UI/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.9, 0.9, 0.9, 1) +theme_override_font_sizes/font_size = 13 +text = "Desktop Companion" +horizontal_alignment = 1 + +[node name="AppSelector" type="OptionButton" parent="UI/MarginContainer/VBoxContainer"] +layout_mode = 2 +tooltip_text = "Seleziona l'applicazione da ascoltare (Spotify, Chrome, ecc.)" + +[node name="VolumeBar" type="ProgressBar" parent="UI/MarginContainer/VBoxContainer"] +custom_minimum_size = Vector2(0, 8) +layout_mode = 2 +max_value = 1.0 +show_percentage = false + +[node name="DebugLabel" type="Label" parent="UI/MarginContainer/VBoxContainer"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.65, 0.9, 0.75, 1) +theme_override_font_sizes/font_size = 12 +text = "Stato: IDLE | Volume: 0.00 | Rilevamento..." +horizontal_alignment = 1 diff --git a/scripts/CompanionController.gd b/scripts/CompanionController.gd new file mode 100644 index 0000000..970aab8 --- /dev/null +++ b/scripts/CompanionController.gd @@ -0,0 +1,214 @@ +class_name CompanionController +extends Node2D + +enum State { + IDLE, + DANCE, + SLEEP +} + +@export_group("Audio Reactivity") +@export_range(0.01, 1.0, 0.01) var audio_threshold: float = 0.04 +@export var dance_decay_time: float = 0.5 +@export var idle_to_sleep_time: float = 2.5 + +@export_group("Rhythm & Squish") +@export_range(0.05, 0.5, 0.01) var squish_amount: float = 0.22 +@export var min_dance_animation_speed: float = 0.9 +@export var max_dance_animation_speed: float = 2.0 + +@export_group("Dependencies") +@export var audio_analyzer: SystemAudioAnalyzer +@export var sprite: AnimatedSprite2D +@export var debug_label: Label +@export var volume_bar: ProgressBar +@export var app_selector: OptionButton + +var current_state: State = State.IDLE +var _silence_timer: float = 0.0 +var _current_rms: float = 0.0 + +# BPM dinamico: parte da 0 finché la canzone non viene agganciata +var _current_bpm: float = 0.0 +var _target_bpm: float = 0.0 +var _base_scale: Vector2 = Vector2.ONE +var _beat_phase: float = 0.0 +var _current_squish: float = 0.0 +var _breathe_time: float = 0.0 + +var _selected_pid: int = 0 +var _known_apps: Array[Dictionary] = [] + +func _ready() -> void: + _base_scale = scale + _current_bpm = 0.0 + _target_bpm = 0.0 + + if audio_analyzer: + audio_analyzer.volume_updated.connect(_on_volume_updated) + audio_analyzer.bpm_updated.connect(_on_bpm_updated) + audio_analyzer.applications_updated.connect(_on_applications_updated) + + if app_selector: + app_selector.item_selected.connect(_on_app_selected) + app_selector.clear() + app_selector.add_item("Tutto il sistema", 0) + + _set_state(State.IDLE) + +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: + 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 + break + if is_same: + return + + _known_apps = apps + var current_selected_pid = _selected_pid + + app_selector.clear() + var selected_idx = 0 + + for i in range(apps.size()): + var app = apps[i] + var label_text = app["name"] + if app["pid"] == 0: + label_text = "Tutto il sistema" + else: + label_text = "%s (PID %d)" % [app["name"], app["pid"]] + + app_selector.add_item(label_text, i) + if app["pid"] == current_selected_pid: + selected_idx = i + + app_selector.select(selected_idx) + +func _on_app_selected(index: int) -> void: + if index >= 0 and index < _known_apps.size(): + var chosen = _known_apps[index] + _selected_pid = chosen["pid"] + # Resetta il BPM per rilevare prontamente la nuova applicazione + _target_bpm = 0.0 + _current_bpm = 0.0 + if audio_analyzer: + audio_analyzer.select_application_pid(_selected_pid) + +func _on_bpm_updated(detected_bpm: float) -> void: + if detected_bpm > 50.0: + _target_bpm = detected_bpm + if _current_bpm <= 10.0: + _current_bpm = detected_bpm + +func _process(delta: float) -> void: + if _target_bpm > 50.0: + _current_bpm = lerpf(_current_bpm, _target_bpm, delta * 3.0) + + _handle_state_logic(delta) + _handle_rhythm_squish(delta) + _update_debug_ui() + +func _on_volume_updated(rms: float) -> void: + _current_rms = lerpf(_current_rms, rms, 0.35) + +func _handle_state_logic(delta: float) -> void: + var is_audio_playing: bool = _current_rms >= audio_threshold + + match current_state: + State.DANCE: + if is_audio_playing: + _silence_timer = 0.0 + _scale_dance_speed(_current_rms) + else: + _silence_timer += delta + if _silence_timer >= dance_decay_time: + _set_state(State.IDLE) + + State.IDLE: + if is_audio_playing: + _set_state(State.DANCE) + else: + _silence_timer += delta + if _silence_timer >= idle_to_sleep_time: + _set_state(State.SLEEP) + + State.SLEEP: + if is_audio_playing: + _set_state(State.DANCE) + +func _set_state(new_state: State) -> void: + current_state = new_state + _silence_timer = 0.0 + + match current_state: + State.IDLE: + if sprite: + sprite.speed_scale = 1.0 + _play_animation("idle") + State.DANCE: + _scale_dance_speed(_current_rms) + _play_animation("dance") + State.SLEEP: + if sprite: + sprite.speed_scale = 1.0 + _play_animation("sleep") + +func _scale_dance_speed(rms: float) -> void: + var normalized_intensity: float = clampf((rms - audio_threshold) * 2.5, 0.0, 1.0) + var target_speed: float = lerpf(min_dance_animation_speed, max_dance_animation_speed, normalized_intensity) + if sprite: + sprite.speed_scale = target_speed + +func _play_animation(anim_name: String) -> void: + if sprite and sprite.sprite_frames and sprite.sprite_frames.has_animation(anim_name): + sprite.play(anim_name) + +func _handle_rhythm_squish(delta: float) -> void: + match current_state: + State.DANCE: + var effective_bpm = _current_bpm if _current_bpm > 50.0 else 130.0 + var beats_per_second: float = effective_bpm / 60.0 + _beat_phase += delta * beats_per_second * TAU + + var raw_wave: float = -cos(_beat_phase) + var shaped_wave: float = sign(raw_wave) * pow(abs(raw_wave), 0.75) + + var dynamic_depth: float = squish_amount * clampf(0.8 + _current_rms * 0.8, 0.8, 1.5) + var target_squish: float = shaped_wave * dynamic_depth + + _current_squish = lerpf(_current_squish, target_squish, delta * 25.0) + + scale.x = _base_scale.x * (1.0 + _current_squish * 0.85) + scale.y = _base_scale.y * (1.0 - _current_squish) + + State.IDLE: + _current_squish = lerpf(_current_squish, 0.0, delta * 6.0) + _breathe_time += delta * 2.2 + var breathe: float = sin(_breathe_time) * 0.025 + scale = Vector2(_base_scale.x * (1.0 + breathe), _base_scale.y * (1.0 - breathe)) + + State.SLEEP: + _current_squish = lerpf(_current_squish, 0.0, delta * 6.0) + _breathe_time += delta * 1.2 + var deep_breathe: float = sin(_breathe_time) * 0.018 + scale = Vector2(_base_scale.x * (1.0 - deep_breathe), _base_scale.y * (1.0 + deep_breathe)) + +func _update_debug_ui() -> void: + if volume_bar: + volume_bar.value = _current_rms + if debug_label: + var bpm_str = "Rilevamento..." + if _current_bpm > 50.0: + bpm_str = "%d BPM" % round(_current_bpm) + + debug_label.text = "Stato: %s | Volume: %.2f | %s" % [ + State.keys()[current_state], + _current_rms, + bpm_str + ] diff --git a/scripts/CompanionController.gd.uid b/scripts/CompanionController.gd.uid new file mode 100644 index 0000000..5d6fcde --- /dev/null +++ b/scripts/CompanionController.gd.uid @@ -0,0 +1 @@ +uid://b8bfvlbipx8j8 diff --git a/scripts/SystemAudioAnalyzer.gd b/scripts/SystemAudioAnalyzer.gd new file mode 100644 index 0000000..64121dc --- /dev/null +++ b/scripts/SystemAudioAnalyzer.gd @@ -0,0 +1,111 @@ +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]) + +@export var udp_port: int = 4242 +@export var cmd_port: int = 4243 + +@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 + +func _ready() -> void: + _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: + 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) + +## Invia al bridge la richiesta di ascoltare SOLO l'applicazione con il PID specificato (0 = Tutto) +func select_application_pid(pid: int) -> void: + 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) + + # 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 + + # Pacchetto audio standard: 8 byte (4 byte RMS + 4 byte BPM) + if packet.size() >= 4: + _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) + + # 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_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 name = parts[1] + apps.append({"pid": pid, "name": 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() diff --git a/scripts/SystemAudioAnalyzer.gd.uid b/scripts/SystemAudioAnalyzer.gd.uid new file mode 100644 index 0000000..94cc2c3 --- /dev/null +++ b/scripts/SystemAudioAnalyzer.gd.uid @@ -0,0 +1 @@ +uid://c76vsjv0asi61