From 4b1fc0835a1597e2add6468c7ccfd7a2687c6a57 Mon Sep 17 00:00:00 2001 From: "m.cirafisi" Date: Tue, 3 Nov 2026 23:07:33 +0100 Subject: [PATCH] first commit --- .editorconfig | 4 + .gitattributes | 2 + .gitignore | 6 + bin/LoopbackCapture.cs | 606 +++++++++++++++++++++++++++++ bin/LoopbackCapture.exe | Bin 0 -> 13312 bytes default_bus_layout.tres | 14 + icon.svg | 1 + icon.svg.import | 43 ++ project.godot | 37 ++ scenes/Main.tscn | 93 +++++ scripts/CompanionController.gd | 214 ++++++++++ scripts/CompanionController.gd.uid | 1 + scripts/SystemAudioAnalyzer.gd | 111 ++++++ scripts/SystemAudioAnalyzer.gd.uid | 1 + 14 files changed, 1133 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 bin/LoopbackCapture.cs create mode 100644 bin/LoopbackCapture.exe create mode 100644 default_bus_layout.tres create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 project.godot create mode 100644 scenes/Main.tscn create mode 100644 scripts/CompanionController.gd create mode 100644 scripts/CompanionController.gd.uid create mode 100644 scripts/SystemAudioAnalyzer.gd create mode 100644 scripts/SystemAudioAnalyzer.gd.uid 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 0000000000000000000000000000000000000000..3c986355ecfe73a1cd4447e11af339431788ba9e GIT binary patch literal 13312 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 oA3D6{1TYL8u8a^6KHC5OY 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