// Exit codes: 0 = clean exit / parent ended; 2 = no render endpoint; // 3 = instance already running; 4 = WASAPI initialization failed; // 5 = unhandled exception / persistent capture error; // 6 = lease expired (Godot is not responding). using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text; using System.Threading; namespace LoopbackCapture { static class GenreSources { // Sorgente della classificazione di genere. // Soluzione 1: metadati del player (GSMTC) + lookup online. // Soluzione 2: modello ONNX locale (fase 2; se il modello manca si ricade sulle regole). internal const bool EnableMetadataGenre = true; internal const bool EnableOnnxGenre = false; // Ancoraggio del BPM rilevato al tempo fornito dai metadati del player. internal const bool EnableMetadataBpm = true; static GenreMetadataProvider metadata; static GenreOnnxProvider onnx; internal static void InitializeOnnx(int rate, string dump) { InitializeOnnxIfEnabled(EnableOnnxGenre, rate, dump); } static void InitializeOnnxIfEnabled(bool enabled, int rate, string dump) { if (!enabled) return; onnx = new GenreOnnxProvider(rate, dump); onnx.Start(); } internal static void AddOnnxSample(float sample) { onnx?.AddSample(sample); } internal static void Start() { StartMetadata(EnableMetadataGenre || EnableMetadataBpm); } static void StartMetadata(bool enabled) { try { if (!enabled) return; // wantGenre tells the provider whether iTunes is needed at all; with // EnableMetadataGenre=false it only resolves the BPM (Deezer), no iTunes. metadata = new GenreMetadataProvider(EnableMetadataGenre); metadata.Start(); } catch { metadata = null; } } internal static GenrePrediction GetPrediction(out string source) { return Select(EnableMetadataGenre, EnableOnnxGenre, out source); } static GenrePrediction Select(bool useMetadata, bool useOnnx, out string source) { source = "rules"; try { string label = useMetadata ? metadata?.GetLabel() : null; if (label != null) { int index = Array.IndexOf(GenreOnnxProvider.Labels, label); if (index >= 0) { var probabilities = new float[5]; probabilities[index] = 1; source = "metadata"; return new GenrePrediction(probabilities, source, Environment.TickCount64 + 10000, metadata?.GetDetail()); } } if (useOnnx) { GenrePrediction result = onnx?.GetPrediction(); if (result != null) { source = "onnx"; return result; } } } catch { } source = "rules"; return null; } // Ancoraggio BPM: inoltra al provider di metadati. Restituisce false se il flag // e' disabilitato, se manca uno snapshot valido o se il bpm non e' disponibile (0). internal static bool TryGetMetadataBpm(out float bpm) { return TryGetMetadataBpm(EnableMetadataBpm, out bpm); } static bool TryGetMetadataBpm(bool enabled, out float bpm) { bpm = 0; if (!enabled) return false; try { return metadata?.TryGetBpm(out bpm) ?? false; } catch { return false; } } // Raw metadata BPM for the DIAG "anchor" field, independent of EnableMetadataBpm: // it reports what the metadata source would provide even when anchoring is off. static float GetMetadataBpmForDiag() { try { float bpm; return metadata != null && metadata.TryGetBpm(out bpm) ? bpm : 0; } catch { return 0; } } static string SanitizeDiagTrack(string value, int maxLength) { if (string.IsNullOrEmpty(value)) return ""; StringBuilder text = new StringBuilder(Math.Min(value.Length, maxLength)); foreach (char c in value) { if (text.Length >= maxLength) break; if (c == ';' || c == '=' || c == '\r' || c == '\n') text.Append(' '); else text.Append(c); } return text.ToString().Trim(); } // Assemble the DIAG: packet from per-source state. Called on the capture thread // every 5 s; reads only atomic string/number fields (no locks in the hot path). internal static string BuildDiagPacket(float detectedBpm) { string meta, itunes, deezer, track; if (metadata == null) { // Provider not started (both EnableMetadataGenre and EnableMetadataBpm off). meta = "off"; itunes = "off"; deezer = "off"; track = ""; } else { meta = metadata.MetaStatus ?? "error"; itunes = metadata.ItunesStatus ?? "off"; deezer = metadata.DeezerStatus ?? "off"; track = metadata.TrackText ?? ""; } string onnxStatus = onnx == null ? "off" : (onnx.Status ?? "failed"); track = SanitizeDiagTrack(track, 80); float anchor = GetMetadataBpmForDiag(); return "DIAG:1" + ";meta=" + meta + ";track=" + track + ";itunes=" + itunes + ";deezer=" + deezer + ";onnx=" + onnxStatus + ";bpm=" + detectedBpm.ToString("F1", CultureInfo.InvariantCulture) + ";anchor=" + anchor.ToString("F1", CultureInfo.InvariantCulture); } } // Independent analysis branch: never writes to RMS/onset/BPM detector state. sealed class RuleGenreAnalyzer { // TUNING TABLE: initial heuristics, NOT calibrated probabilities. Adjust here. const int FftSize = 2048, ShortHops = 400, RhythmHops = 1600; const int DynamicsSamples = 160, DynamicsHopCount = 10, MinDynamicsSamples = 40; const int MinBars = 4, MinPeaks = 4; const double FeatureAlpha = 0.05, Temperature = 0.20, ConfidenceThreshold = 0.30; const double BandMinHz = 100, BandMaxHz = 8000, HighHz = 2000; const double LowMinHz = 40, LowMaxHz = 120, MidMinHz = 200, MidMaxHz = 5000; const double CentroidMinHz = 500, CentroidMaxHz = 4000; const double FlatnessMin = 0.02, FlatnessMax = 0.50; const double HighRatioMin = 0.05, HighRatioMax = 0.60, LowRatioMin = 0.05, LowRatioMax = 0.50; const double DensityMax = 8, DynamicsMinDb = 6, DynamicsMaxDb = 24; const double LatinMinBpm = 85, LatinMaxBpm = 105, ElectronicMinBpm = 115, ElectronicMaxBpm = 135; const double HipHopMinBpm = 80, HipHopMaxBpm = 95, TempoShoulderBpm = 15; // Il dembow e' una firma discreta, non un contributo lineare: sopra soglia sposta // la decisione, sotto soglia non tocca nulla. Richiede anche affinita' di tempo // >= 0.5, altrimenti una sincope qualsiasi a 130 BPM verrebbe promossa a latin. // La soglia lavora sul dembow NORMALIZZATO (vedi DembowBaseline). const double DembowGate = 0.40, DembowBonus = 0.35; // Un istogramma uniforme correla gia' 7/16 con un template che ha 7 caselle attive // su 16. Senza togliere questo pavimento, qualsiasi ritmo piatto (rock con piatti // ovunque) ottiene ~0.44 e passa per un dembow debole. const double DembowBaseline = 7.0 / 16.0; // Il rilevatore puo' agganciare la cella 3+3+2 e riportare 4/3 del battito. // Si prova anche l'ipotesi 3/4 e si tiene quella che spiega meglio il pattern. const double FoldMargin = 0.05, MinFoldBpm = 50; static readonly double[] FoldFactors = { 1.0, 0.75, 1.25 }; const double RegularityCenter = 0.50, RegularityWidth = 0.50; const double PeakSigma = 1, PowerEpsilon = 1e-20, RmsFloor = 1e-6, ActiveRms = 1e-5; const int PublishMs = 1000, StaleMs = 1000; // Columns: dembow, fourFloor, tempo affinity, low, flatness, high, density, // centroid, regularity, medium regularity, dynamics. Negative = inhibitory. // Each row has total absolute weight 1; Bias implements rewarded complements. // Scores include Bias, then stable softmax(score / Temperature). static readonly string[] Labels = { "latin", "electronic", "hiphop", "rock", "classical" }; // Pesi derivati dalla distanza di Fisher misurata su dati reali etichettati // (tests\rock2.csv, classic.csv, hiphop.csv). Le feature sono quasi ortogonali: // flatness/highRatio separano classical da {rock, hiphop} ma NON rock da hiphop (d'~0.03); // regularity/regMid/dynamics separano rock da hiphop ma non rock da classical. // density non discrimina nessuna delle tre coppie (d' 0.14-0.45): peso azzerato. // I Bias NON sono scelti a mano: sono il valore che porta ogni classe a 0 sulla media // globale delle feature (bias_c = -w_c . mean). Senza questa compensazione una classe // diventa il "vincitore per default" e ogni ritaratura sposta il problema su un'altra. static readonly double[] Bias = { -0.273, -0.119, -0.197, 0.018, 0.191 }; static readonly double[,] Weights = { { 0.50, 0, 0.25, 0.25, 0, 0, 0, 0, 0, 0, 0 }, { 0, 0.35, 0.25, 0.15, 0, 0, 0, 0, 0.25, 0, 0 }, { 0, 0, 0, 0.15, 0, 0, 0, 0, 0.20, 0.35, 0.30 }, { 0, 0, 0, 0, 0.25, 0.15, 0, 0, -0.20, -0.25, -0.15 }, { 0, 0, 0, 0, -0.40, -0.45, 0, 0, 0, 0, 0.15 } }; static readonly int[] DembowTemplate = { 1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0 }; readonly int rate, hop; readonly double[] samples = new double[FftSize], hann = new double[FftSize]; readonly double[] real = new double[FftSize], imag = new double[FftSize]; readonly double[] cos = new double[FftSize / 2], sin = new double[FftSize / 2]; readonly int[] reverse = new int[FftSize]; readonly double[] previousPower = new double[FftSize / 2 + 1]; readonly double[] shortOnset = new double[ShortHops], rhythm = new double[RhythmHops]; readonly double[] envelope = new double[ShortHops], folded = new double[16]; readonly double[] dynamics = new double[DynamicsSamples], sortedDynamics = new double[DynamicsSamples]; readonly double[] features = new double[11], probabilities = new double[5]; int write, filled, hopFrames, shortHead, shortCount, rhythmHead, rhythmCount; int dynamicsHead, dynamicsCount, dynamicsHops, dynamicsFrames; double dynamicsSum, recentRms, centroid, flatness, highRatio, lowRatio; double dembow, fourFloor, density, regularity, dynamicRange; bool spectrumReady, dynamicReady; long lastDataMs = -1, lastPublishMs; StreamWriter dump; public double DembowPhase { get; private set; } // Tempo effettivamente usato per il folding: puo' essere i 3/4 di quello rilevato // se il pattern dimostra che il detector ha agganciato la cella 3+3+2. public double EffectiveBpm { get; private set; } public RuleGenreAnalyzer(int sampleRate, int hopSize, string dumpPath) { rate = sampleRate; hop = hopSize; for (int i = 0; i < FftSize; i++) { hann[i] = 0.5 - 0.5 * Math.Cos(2 * Math.PI * i / (FftSize - 1)); int x = i, r = 0; for (int b = 0; b < 11; b++) { r = (r << 1) | (x & 1); x >>= 1; } reverse[i] = r; } for (int i = 0; i < cos.Length; i++) { cos[i] = Math.Cos(-2 * Math.PI * i / FftSize); sin[i] = Math.Sin(-2 * Math.PI * i / FftSize); } if (!string.IsNullOrEmpty(dumpPath)) { try { dump = new StreamWriter(dumpPath, false, Encoding.UTF8); dump.WriteLine("ms,bpm,dembow,fourfloor,density,flatness,centroid,regularity,highRatio,lowRatio,label,conf,f0_latin_dembow,f1_fourfloor,f2_tempo_latin,f3_low,f4_flat,f5_high,f6_density,f7_centroid,f8_reg,f9_regmid,f10_dyn,s_latin,s_electronic,s_hiphop,s_rock,s_classical"); dump.Flush(); } catch (Exception ex) { DisableDump(ex); } } } void DisableDump(Exception ex) { Console.Error.WriteLine("Feature dump disabled: " + ex.Message); if (dump != null) { try { dump.Dispose(); } catch { } dump = null; } } public void MarkData(long now) { lastDataMs = now; } // Il folding ritmico dipende dal tempo: se il BPM cambia (nuovo brano), la storia // gia' registrata e' su una griglia che non corrisponde piu'. Va scartata, altrimenti // per fino a 16 s si pubblica un dembow/fourFloor casuale. public void ResetRhythm() { rhythmHead = 0; rhythmCount = 0; Array.Clear(rhythm, 0, rhythm.Length); dembow = 0; fourFloor = 0; DembowPhase = 0; } // Errore di ottava del rilevatore: 85 BPM puo' uscire 170, 195 puo' uscire 97.5. // Riporta il tempo in [70,145) prima di applicare sia i template di tempo sia il // folding ritmico. Senza questa normalizzazione nessuna regola di tempo entra in // gioco (LatinMin 85, HipHopMin 80) e vince sempre la classe senza termine ritmico. public static double CanonicalTempo(double bpm) { if (bpm <= 0) return 0; while (bpm >= 145.0) bpm /= 2.0; while (bpm < 70.0) bpm *= 2.0; return bpm; } public void AddSample(float value) { double v = float.IsNaN(value) || float.IsInfinity(value) ? 0 : value; samples[write] = v; write = (write + 1) % FftSize; if (filled < FftSize) filled++; dynamicsSum += v * v; dynamicsFrames++; if (++hopFrames < hop) return; hopFrames = 0; double flux = filled == FftSize ? Spectrum() : 0; shortOnset[shortHead] = flux; shortHead = (shortHead + 1) % ShortHops; shortCount = Math.Min(shortCount + 1, ShortHops); rhythm[rhythmHead] = flux; rhythmHead = (rhythmHead + 1) % RhythmHops; rhythmCount = Math.Min(rhythmCount + 1, RhythmHops); if (++dynamicsHops >= DynamicsHopCount) { recentRms = Math.Sqrt(dynamicsSum / Math.Max(1, dynamicsFrames)); dynamics[dynamicsHead] = 20 * Math.Log10(Math.Max(RmsFloor, recentRms)); dynamicsHead = (dynamicsHead + 1) % DynamicsSamples; dynamicsCount = Math.Min(dynamicsCount + 1, DynamicsSamples); dynamicsSum = 0; dynamicsFrames = 0; dynamicsHops = 0; } } // Real input, radix-2 complex workspace; only nonnegative frequency bins used. // All FFT/window/twiddle/history arrays are preallocated and reused per hop. double Spectrum() { for (int i = 0; i < FftSize; i++) { real[reverse[i]] = samples[(write + i) % FftSize] * hann[i]; imag[reverse[i]] = 0; } for (int size = 2; size <= FftSize; size <<= 1) { int half = size / 2, stride = FftSize / size; for (int start = 0; start < FftSize; start += size) for (int j = 0; j < half; j++) { int a = start + j, b = a + half, t = j * stride; double tr = cos[t] * real[b] - sin[t] * imag[b]; double ti = sin[t] * real[b] + cos[t] * imag[b]; real[b] = real[a] - tr; imag[b] = imag[a] - ti; real[a] += tr; imag[a] += ti; } } double total = 0, band = 0, weighted = 0, logs = 0, high = 0, low = 0, flux = 0; int bins = 0; for (int k = 0; k <= FftSize / 2; k++) { double hz = k * (double)rate / FftSize; double p = (real[k] * real[k] + imag[k] * imag[k]) / (FftSize * (double)FftSize); // One-sided power: DC/Nyquist occur once, other bins twice. if (k > 0 && k < FftSize / 2) p *= 2; total += p; if (hz >= BandMinHz && hz <= BandMaxHz) { band += p; weighted += hz * p; logs += Math.Log(Math.Max(PowerEpsilon, p)); bins++; } if (hz > HighHz) high += p; if (hz >= LowMinHz && hz <= LowMaxHz) low += p; if (hz >= MidMinHz && hz <= MidMaxHz && spectrumReady) flux += Math.Max(0, p - previousPower[k]); previousPower[k] = p; } double smoothing = spectrumReady ? FeatureAlpha : 1; centroid += smoothing * ((band > PowerEpsilon ? weighted / band : 0) - centroid); flatness += smoothing * ((band > PowerEpsilon && bins > 0 ? Clamp(Math.Exp(logs / bins) / (band / bins)) : 0) - flatness); highRatio += smoothing * ((total > PowerEpsilon ? high / total : 0) - highRatio); lowRatio += smoothing * ((total > PowerEpsilon ? low / total : 0) - lowRatio); spectrumReady = true; return flux; } static double Clamp(double x) { return Math.Max(0, Math.Min(1, x)); } static double Normalize(double x, double min, double max) { return Clamp((x - min) / (max - min)); } static double Tempo(double bpm, double min, double max) { return bpm <= 0 ? 0 : Clamp(1 - Math.Max(min - bpm, Math.Max(0, bpm - max)) / TempoShoulderBpm); } static string SanitizeDetail(string value) { StringBuilder text = new StringBuilder(value.Length); foreach (char c in value) { if (c == ';' || c == '=' || c == '\r' || c == '\n') text.Append(' '); else text.Append(c); } return text.ToString().Trim(); } void RhythmFeatures(double bpm) { double maximum = PowerEpsilon; for (int i = 0; i < shortCount; i++) { envelope[i] = shortOnset[(shortHead - shortCount + i + ShortHops) % ShortHops]; maximum = Math.Max(maximum, envelope[i]); } double mean = 0, variance = 0; for (int i = 0; i < shortCount; i++) { envelope[i] /= maximum; mean += envelope[i]; } mean /= Math.Max(1, shortCount); for (int i = 0; i < shortCount; i++) variance += (envelope[i] - mean) * (envelope[i] - mean); double threshold = mean + PeakSigma * Math.Sqrt(variance / Math.Max(1, shortCount)); int peaks = 0, lastPeak = -1, intervals = 0; double sum = 0, squares = 0; for (int i = 1; i < shortCount - 1; i++) if (envelope[i] > threshold && envelope[i] > envelope[i - 1] && envelope[i] >= envelope[i + 1]) { peaks++; if (lastPeak >= 0) { double d = i - lastPeak; sum += d; squares += d * d; intervals++; } lastPeak = i; } // Available duration during warm-up; exactly 4 s once the 400-slot ring is full. density = peaks / Math.Max(0.01, shortCount / 100.0); regularity = peaks < MinPeaks ? 0 : 1 - Clamp(Math.Sqrt(Math.Max(0, squares / intervals - Math.Pow(sum / intervals, 2))) / (sum / intervals)); dembow = 0; fourFloor = 0; DembowPhase = 0; EffectiveBpm = 0; if (bpm <= 0) return; // Il pattern 3+3+2 crea periodicita' spurie a 4/3 e a 5/4 del battito: il // detector puo' agganciare una cella invece del battito (95 BPM letti 127 o 76). // Non lo si decide a priori: vince l'ipotesi che correla meglio col template. double bestD = 0, bestF = 0, bestP = 0, bestT = 0; bool found = false; for (int h = 0; h < FoldFactors.Length; h++) { double d, f, p; if (!Fold(bpm * FoldFactors[h], out d, out f, out p)) continue; if (!found || d > bestD + FoldMargin) { bestD = d; bestF = f; bestP = p; bestT = bpm * FoldFactors[h]; found = true; } } if (found) { dembow = bestD; fourFloor = bestF; DembowPhase = bestP; EffectiveBpm = bestT; } } bool Fold(double bpm, out double dembowScore, out double fourFloorScore, out double phaseScore) { dembowScore = 0; fourFloorScore = 0; phaseScore = 0; if (bpm < MinFoldBpm) return false; double hopPerBeat = 60 / bpm * 100, barHops = hopPerBeat * 4; int bars = (int)Math.Floor(rhythmCount / barHops); if (bars < MinBars) return false; int count = (int)Math.Floor(bars * barHops); Array.Clear(folded, 0, folded.Length); double maximum = PowerEpsilon; for (int i = 0; i < count; i++) maximum = Math.Max(maximum, rhythm[(rhythmHead - count + i + RhythmHops) % RhythmHops]); for (int i = 0; i < count; i++) folded[((int)Math.Floor(i / hopPerBeat * 4)) % 16] += rhythm[(rhythmHead - count + i + RhythmHops) % RhythmHops] / maximum / bars; double sum = 0; for (int i = 0; i < 16; i++) sum += folded[i]; if (sum <= PowerEpsilon) return false; for (int i = 0; i < 16; i++) folded[i] /= sum; for (int phase = 0; phase < 16; phase++) { double correlation = 0; for (int i = 0; i < 16; i++) correlation += folded[(i + phase) % 16] * DembowTemplate[i]; // Unit-sum folded envelope, binary template: theoretical maximum dot = 1. if (correlation > dembowScore) { dembowScore = Clamp(correlation); phaseScore = phase / 16.0; } double mass = 0, weakest = 1; for (int q = 0; q < 4; q++) { double v = folded[(phase + 4 * q) % 16]; mass += v; weakest = Math.Min(weakest, v); } // quarterMass * (weakest / meanQuarter): penalizes missing quarters. // Uniform 16 bins => .25; four equal quarter hits => 1; missing hit => 0. double floor = mass > 0 ? mass * Clamp(weakest / (mass / 4)) : 0; fourFloorScore = Math.Max(fourFloorScore, Clamp(floor)); } // Rimuove il pavimento del 7/16: 0 = nessuna informazione ritmica, 1 = dembow pieno. dembowScore = Clamp((dembowScore - DembowBaseline) / (1 - DembowBaseline)); return true; } double Percentile(double fraction) { double index = (dynamicsCount - 1) * fraction; int lo = (int)index, hi = Math.Min(lo + 1, dynamicsCount - 1); return sortedDynamics[lo] + (sortedDynamics[hi] - sortedDynamics[lo]) * (index - lo); } public void Publish(long now, double bpm, UdpClient udp, IPEndPoint destination) { if (now - lastPublishMs < PublishMs) return; lastPublishMs = now; bool fresh = lastDataMs >= 0 && now - lastDataMs <= StaleMs; RhythmFeatures(bpm); // Il termine di tempo usa il BPM che ha vinto il confronto di folding, // non quello grezzo del rilevatore. double tempo = EffectiveBpm > 0 ? EffectiveBpm : bpm; dynamicReady = dynamicsCount >= MinDynamicsSamples; dynamicRange = 0; if (dynamicReady) { Array.Copy(dynamics, sortedDynamics, dynamicsCount); Array.Sort(sortedDynamics, 0, dynamicsCount); dynamicRange = Percentile(0.90) - Percentile(0.10); } features[0] = dembow; features[1] = fourFloor; features[3] = Normalize(lowRatio, LowRatioMin, LowRatioMax); features[4] = Normalize(flatness, FlatnessMin, FlatnessMax); features[5] = Normalize(highRatio, HighRatioMin, HighRatioMax); features[6] = Normalize(density, 0, DensityMax); features[7] = Normalize(centroid, CentroidMinHz, CentroidMaxHz); features[8] = regularity; features[9] = Clamp(1 - Math.Abs(regularity - RegularityCenter) / RegularityWidth); features[10] = dynamicReady ? Normalize(dynamicRange, DynamicsMinDb, DynamicsMaxDb) : 0; double maxScore = double.NegativeInfinity; double[] rawScores = new double[Labels.Length]; for (int c = 0; c < Labels.Length; c++) { features[2] = c == 0 ? Tempo(tempo, LatinMinBpm, LatinMaxBpm) : c == 1 ? Tempo(tempo, ElectronicMinBpm, ElectronicMaxBpm) : c == 2 ? Tempo(tempo, HipHopMinBpm, HipHopMaxBpm) : 0; double score = Bias[c]; for (int j = 0; j < features.Length; j++) score += features[j] * Weights[c, j]; // Dembow come soglia, non come peso: un pattern 3+3+2 riconosciuto vale // piu' di un contributo lineare, ma solo se il tempo e' compatibile. if (c == 0 && dembow >= DembowGate && Tempo(tempo, LatinMinBpm, LatinMaxBpm) >= 0.5) score += DembowBonus; probabilities[c] = score; rawScores[c] = score; maxScore = Math.Max(maxScore, score); } double total = 0; bool active = fresh && recentRms >= ActiveRms && shortCount >= ShortHops; for (int c = 0; c < Labels.Length; c++) { probabilities[c] = active ? Math.Exp((probabilities[c] - maxScore) / Temperature) : 1; total += probabilities[c]; } for (int c = 0; c < Labels.Length; c++) probabilities[c] /= total; int best = 0; for (int c = 1; c < Labels.Length; c++) if (probabilities[c] > probabilities[best]) best = c; string label = probabilities[best] < ConfidenceThreshold ? "unknown" : Labels[best]; // Rules above ALWAYS run. Override only the wire output, not their history/dump. string source = "rules"; GenrePrediction preferred = null; try { preferred = GenreSources.GetPrediction(out source); } catch { source = "rules"; } StringBuilder packet = new StringBuilder(); packet.AppendFormat(CultureInfo.InvariantCulture, "GENR:2;{0};{1:F3};{2}", preferred?.Label ?? label, preferred != null ? preferred.Confidence : probabilities[best], source); // Genere specifico del catalogo: descrittivo, non partecipa alle probabilita'. // ';' e '=' sono delimitatori del protocollo: sostituiti prima dell'invio. if (preferred != null && !string.IsNullOrEmpty(preferred.Detail)) packet.Append(";detail=").Append(SanitizeDetail(preferred.Detail)); for (int c = 0; c < Labels.Length; c++) packet.AppendFormat(CultureInfo.InvariantCulture, ";{0}={1:F3}", Labels[c], preferred == null ? probabilities[c] : preferred.Probabilities[c]); try { byte[] bytes = Encoding.UTF8.GetBytes(packet.ToString()); udp.Send(bytes, bytes.Length, destination); } catch { } if (dump != null) { try { StringBuilder row = new StringBuilder(); row.AppendFormat(CultureInfo.InvariantCulture, "{0},{1:F3},{2:F3},{3:F3},{4:F3},{5:F6},{6:F3},{7:F3},{8:F6},{9:F6},{10},{11:F3}", now, tempo, fresh ? dembow : 0, fresh ? fourFloor : 0, fresh ? density : 0, fresh ? flatness : 0, fresh ? centroid : 0, fresh ? regularity : 0, fresh ? highRatio : 0, fresh ? lowRatio : 0, label, probabilities[best]); // Valori normalizzati visti dal classificatore + punteggi grezzi per classe: // servono a capire QUALE termine fa perdere una classe, non solo che perde. for (int j = 0; j < features.Length; j++) row.AppendFormat(CultureInfo.InvariantCulture, ",{0:F3}", features[j]); for (int c = 0; c < Labels.Length; c++) row.AppendFormat(CultureInfo.InvariantCulture, ",{0:F3}", rawScores[c]); dump.WriteLine(row.ToString()); dump.Flush(); // Once a second, never on the per-frame path. } catch (Exception ex) { DisableDump(ex); } } } } class Program { const int SIO_UDP_CONNRESET = -1744830452; static int selectedTargetPid = 0; // 0 = Tutto il sistema static Mutex instanceMutex; static ManualResetEvent cmdReady = new ManualResetEvent(false); static long leaseDeadlineMs; // Ancoraggio del BPM rilevato al tempo fornito dai metadati. Metodo puro e // riusabile: usato dal loop di cattura e da --check-anchor. // Il rilevatore puo' leggere 4/3 o 4/5 del tempo reale su pattern 3+3+2, e puo' // sbagliare l'ottava (2x / 1/2x). L'ottava e' decisa dall'energia percussiva e // quindi e' approssimativa (tolleranza 4%), mentre i rapporti 3+3+2 sono // deterministici (tolleranza 2%): cosi' un tempo vicino ma non derivato dai // metadati (es. 130 vs 4/3 * 95 = 126.7) non viene toccato. static int AnchorBpm(double detected, float metadataBpm) { if (metadataBpm <= 0) return (int)Math.Round(detected); double a = metadataBpm; double best = detected, bestTarget = best, bestDist = double.MaxValue; // (valore che il rilevatore puo' leggere, valore corretto, tolleranza) var relations = new (double value, double target, double tol)[] { (a, a, 0.04), (2 * a, 2 * a, 0.04), (a / 2, a / 2, 0.04), (a * 4.0 / 3.0, a, 0.02), (a * 4.0 / 5.0, a, 0.02), }; foreach (var r in relations) { double rel = Math.Abs(best - r.value) / r.value; if (rel <= r.tol && rel < bestDist) { bestDist = rel; bestTarget = r.target; } } return (int)Math.Round(bestTarget); } static void CheckAnchor(string spec) { string[] parts = spec.Split(','); if (parts.Length < 2 || !float.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out float anchor)) { Console.Error.WriteLine("--check-anchor richiede ,,... (numeri)"); return; } for (int i = 1; i < parts.Length; i++) { if (!float.TryParse(parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out float detected)) { Console.Error.WriteLine("--check-anchor: valore non valido: " + parts[i]); continue; } int anchored = AnchorBpm(detected, anchor); Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0:F1} -> {1}", detected, anchored)); } } // Diagnostics for --check-sources: one attempt per source, readable report, exit 0. // Works regardless of EnableMetadataGenre/EnableOnnxGenre, like --check-anchor for // the pure function; the verdict reports what would win with the current config. static void CheckSources(string spec) { try { // 1. Model file presence (same path the ONNX worker resolves). string modelPath = Path.Combine(AppContext.BaseDirectory, "genre_model.onnx"); string jsonPath = Path.Combine(AppContext.BaseDirectory, "genre_model.json"); bool modelExists = File.Exists(modelPath) && File.Exists(jsonPath); Console.WriteLine("[MODEL] genre_model.onnx trovato=" + (modelExists ? "si" : "no") + (modelExists ? "" : " -> ONNX non disponibile")); // 2. ONNX load attempt (only if the files are present). bool onnxLoaded = false; if (!modelExists) { Console.WriteLine("[ONNX] caricamento: saltato (modello assente)"); } else { try { var provider = new GenreOnnxProvider(44100, null); provider.Start(); if (provider.WaitForInitialization(30000)) { onnxLoaded = true; Console.WriteLine("[ONNX] caricamento: ok"); } else { string reason = provider.FailureReason; Console.WriteLine("[ONNX] caricamento: fallito (" + (string.IsNullOrEmpty(reason) ? provider.Status : reason) + ")"); } } catch (Exception ex) { Console.WriteLine("[ONNX] caricamento: fallito (" + ex.Message + ")"); } } // 3. GSMTC / test track. string artist, title; bool gsmPlaying = false; if (!string.IsNullOrEmpty(spec)) { int sep = spec.IndexOf('|'); if (sep < 0) { artist = spec.Trim(); title = ""; } else { artist = spec.Substring(0, sep).Trim(); title = spec.Substring(sep + 1).Trim(); } gsmPlaying = ReadGsmcForReport(out string gArtist, out string gTitle); string gsmText = gsmPlaying ? "si (\"" + gArtist + " - " + gTitle + "\")" : "no"; Console.WriteLine("[GSMTC] sessione in riproduzione: " + gsmText + " [test: \"" + artist + " - " + title + "\"]"); } else { gsmPlaying = ReadGsmcForReport(out artist, out title); if (gsmPlaying) Console.WriteLine("[GSMTC] sessione in riproduzione: si (\"" + artist + " - " + title + "\")"); else { artist = "Daft Punk"; title = "Get Lucky"; Console.WriteLine("[GSMTC] sessione in riproduzione: no -> uso test \"Daft Punk - Get Lucky\""); } } if (artist.Length == 0 && title.Length == 0) { Console.WriteLine("[ITUNES] saltato: nessun artista/titolo"); Console.WriteLine("[DEEZER] saltato: nessun artista/titolo"); Console.WriteLine("[VERDETTO] sorgente che vincerebbe: rules (nessun brano)"); return; } // 4. iTunes. var (label, detail, itunesState, itunesReason) = GenreMetadataProvider.LookupItunesDetailed(artist, title); if (itunesState == "ok") Console.WriteLine("[ITUNES] risposta ok, match=si, genere=\"" + (detail ?? "") + "\" -> " + label); else if (itunesState == "no-match") Console.WriteLine("[ITUNES] risposta ok, match=no -> " + itunesReason); else Console.WriteLine("[ITUNES] errore: " + itunesReason); // 5. Deezer. var (bpm, deezerState, deezerReason) = GenreMetadataProvider.LookupDeezerDetailed(artist, title); if (deezerState == "ok") Console.WriteLine("[DEEZER] risposta ok, match=si, bpm=" + bpm.ToString("F2", CultureInfo.InvariantCulture)); else if (deezerState == "no-bpm") Console.WriteLine("[DEEZER] risposta ok, match=si, bpm assente (" + deezerReason + ")"); else if (deezerState == "no-match") Console.WriteLine("[DEEZER] risposta ok, match=no -> " + deezerReason); else Console.WriteLine("[DEEZER] errore: " + deezerReason); // 6. Provider startup mode under the current configuration. bool metadataEnabled = GenreSources.EnableMetadataGenre; bool metadataBpmEnabled = GenreSources.EnableMetadataBpm; bool onnxEnabled = GenreSources.EnableOnnxGenre; string metaMode; if (!metadataEnabled && !metadataBpmEnabled) metaMode = "off (provider non avviato: genere e BPM disabilitati)"; else if (!metadataEnabled) metaMode = "bpm-only (provider avviato per solo BPM)"; else metaMode = "on (genere" + (metadataBpmEnabled ? " + BPM" : "") + ")"; Console.WriteLine("[META] provider: " + metaMode); // 7. Verdict under the current configuration. bool metadataWins = metadataEnabled && itunesState == "ok"; bool onnxWins = onnxEnabled && onnxLoaded; string winner = metadataWins ? "metadata" : onnxWins ? "onnx" : "rules"; var reasons = new List(); if (!metadataWins) { if (!metadataEnabled) reasons.Add(metadataBpmEnabled ? "metadati solo BPM (genere disabilitato)" : "metadati disabilitati"); else if (itunesState == "no-match") reasons.Add("nessun match iTunes"); else reasons.Add("iTunes non disponibile"); } if (!onnxWins) { if (!onnxEnabled) reasons.Add("ONNX disabilitato"); else if (!modelExists) reasons.Add("ONNX assente"); else reasons.Add("ONNX non caricabile"); } string reasonText = reasons.Count > 0 ? string.Join(", ", reasons) : ""; Console.WriteLine("[VERDETTO] sorgente che vincerebbe: " + winner + (reasonText.Length > 0 ? " (" + reasonText + ")" : "")); } catch (Exception ex) { Console.WriteLine("[VERDETTO] errore imprevisto nel check: " + ex.Message); } } static bool ReadGsmcForReport(out string artist, out string title) { var (a, t) = GenreMetadataProvider.ReadCurrentTrack(); artist = a; title = t; return a != null && t != null; } static void Main(string[] args) { try { Run(args); } catch (Exception ex) { Console.Error.WriteLine(ex); Environment.Exit(5); } } static void Run(string[] args) { Stopwatch lifecycleClock = Stopwatch.StartNew(); int leaseMs = 0; string dumpPath = null; string tensorDump = null, verifyWav = null; int verifyRate = 0; for (int i = 0; i < args.Length; i++) { if (args[i].StartsWith("--dump-tensor=", StringComparison.Ordinal)) tensorDump = args[i].Substring(14); if (args[i].StartsWith("--verify-wav=", StringComparison.Ordinal)) verifyWav = args[i].Substring(13); if (args[i] == "--rate") { if (++i >= args.Length || !int.TryParse(args[i], out verifyRate) || (verifyRate != 44100 && verifyRate != 48000)) throw new ArgumentException("--rate requires 44100 or 48000 (sample reinterpretation, no resampling)"); } } for (int i = 0; i < args.Length; i++) { if (args[i].StartsWith("--check-anchor=", StringComparison.Ordinal)) { CheckAnchor(args[i].Substring(15)); Environment.Exit(0); } } for (int i = 0; i < args.Length; i++) { if (args[i] == "--check-sources") { CheckSources(null); Environment.Exit(0); } if (args[i].StartsWith("--check-sources=", StringComparison.Ordinal)) { CheckSources(args[i].Substring(16)); Environment.Exit(0); } } bool onnxEnabled = GenreSources.EnableOnnxGenre; if ((tensorDump != null || verifyWav != null) && !onnxEnabled) throw new InvalidOperationException("ONNX tensor dump/verification rejected: EnableOnnxGenre=false; frontend not initialized"); if (tensorDump != null && string.IsNullOrWhiteSpace(tensorDump)) throw new ArgumentException("--dump-tensor requires a file path"); if (verifyRate != 0 && verifyWav == null) throw new ArgumentException("--rate is only valid with --verify-wav"); foreach (string arg in args) { if (arg.StartsWith("--dump-features=", StringComparison.Ordinal)) dumpPath = arg.Substring(16); if (arg.StartsWith("--lease-ms=", StringComparison.Ordinal)) { int parsedLease; if (int.TryParse(arg.Substring(11), out parsedLease) && parsedLease > 0) leaseMs = parsedLease; } } Interlocked.Exchange(ref leaseDeadlineMs, lifecycleClock.ElapsedMilliseconds + leaseMs); int port = 4242; int cmdPort = 4243; if (args.Length > 0 && (verifyWav == null || !args[0].StartsWith("--", StringComparison.Ordinal))) { int.TryParse(args[0], out port); cmdPort = port + 1; } if (verifyWav != null) { Environment.Exit(GenreOnnxProvider.VerifyWav(verifyWav, verifyRate, tensorDump, port)); return; } int parentPid = 0; if (args.Length > 1) int.TryParse(args[1], out parentPid); if (parentPid > 0) { Thread parentWatchdog = new Thread(() => { try { using (Process parent = Process.GetProcessById(parentPid)) { parent.WaitForExit(); } } catch { } Environment.Exit(0); }); parentWatchdog.IsBackground = true; parentWatchdog.Start(); } bool createdNew; instanceMutex = new Mutex(false, "Local\\LoopbackCapture_" + port, out createdNew); if (!createdNew) { Console.Error.WriteLine("LoopbackCapture: istanza gia attiva sulla porta " + port); Environment.Exit(3); } GC.KeepAlive(instanceMutex); try { GenreSources.Start(); } catch { } 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); cmdReady.Set(); 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; } } else if (cmd.StartsWith("PING:", StringComparison.Ordinal)) { Interlocked.Exchange(ref leaseDeadlineMs, lifecycleClock.ElapsedMilliseconds + leaseMs); } // Unknown commands are deliberately ignored. } } catch { } }); cmdListenerThread.IsBackground = true; cmdListenerThread.Start(); // Thread.Start alone does not guarantee that the command socket is bound. if (!cmdReady.WaitOne(2000)) Console.Error.WriteLine("LoopbackCapture: command socket not ready after 2000 ms; continuing."); // Announce after command readiness (or its bounded diagnostic timeout). try { byte[] hello = Encoding.UTF8.GetBytes("HELLO:2"); udpSend.Send(hello, hello.Length, remoteEp); } catch { } 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) Environment.Exit(2); } Guid iidMgr = typeof(IAudioSessionManager2).GUID; object oMgr; hr = device.Activate(ref iidMgr, 1, IntPtr.Zero, out oMgr); if (hr != 0 || oMgr == null) Environment.Exit(4); 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) Environment.Exit(4); IAudioClient audioClient = (IAudioClient)oAudioClient; IntPtr pMixFormat; hr = audioClient.GetMixFormat(out pMixFormat); if (hr != 0 || pMixFormat == IntPtr.Zero) Environment.Exit(4); 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) Environment.Exit(4); Guid iidCaptureClient = typeof(IAudioCaptureClient).GUID; object oCaptureClient; hr = audioClient.GetService(ref iidCaptureClient, out oCaptureClient); if (hr != 0 || oCaptureClient == null) Environment.Exit(4); IAudioCaptureClient captureClient = (IAudioCaptureClient)oCaptureClient; hr = audioClient.Start(); if (hr != 0) Environment.Exit(4); // 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; RuleGenreAnalyzer genreAnalyzer = new RuleGenreAnalyzer(sampleRate, hopSize, dumpPath); GenreSources.InitializeOnnx(sampleRate, tensorDump); 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; double foldedBpm = 0.0; // tempo canonico con cui e' stato costruito lo storico ritmico int candidateBpm = 0; int candidateStreak = 0; int bpmEvalCounter = 0; long lastAppListBroadcast = 0; int myPid = Process.GetCurrentProcess().Id; long lastHeartbeat = -250; long heartbeatCounter = 0; long captureErrorSince = -1; long lastDiagBroadcast = -5000; while (true) { if (leaseMs > 0 && lifecycleClock.ElapsedMilliseconds > Interlocked.Read(ref leaseDeadlineMs)) { // Prefer clean parent termination even if its watchdog has not run yet. if (parentPid > 0) { try { using (Process parent = Process.GetProcessById(parentPid)) { if (parent.HasExited) Environment.Exit(0); } } catch { Environment.Exit(0); } } // A PING may have renewed the lease while checking the parent. if (lifecycleClock.ElapsedMilliseconds > Interlocked.Read(ref leaseDeadlineMs)) { Console.Error.WriteLine("LoopbackCapture: lease expired (Godot is not responding)."); Environment.Exit(6); } } if (lifecycleClock.ElapsedMilliseconds - lastHeartbeat >= 250) { lastHeartbeat = lifecycleClock.ElapsedMilliseconds; try { byte[] heartbeat = Encoding.UTF8.GetBytes(string.Format("HB:{0};0", heartbeatCounter++)); udpSend.Send(heartbeat, heartbeat.Length, remoteEp); } catch { } } // Per-source diagnostic packet, every 5 s, same socket/destination as the rest. if (lifecycleClock.ElapsedMilliseconds - lastDiagBroadcast >= 5000) { lastDiagBroadcast = lifecycleClock.ElapsedMilliseconds; try { byte[] diag = Encoding.UTF8.GetBytes(GenreSources.BuildDiagPacket(detectedBpm)); udpSend.Send(diag, diag.Length, remoteEp); } catch { } } // 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 (numFramesRead > 0) genreAnalyzer.MarkData(lifecycleClock.ElapsedMilliseconds); 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; } genreAnalyzer.AddSample((flags & 2) != 0 ? 0f : monoSample); GenreSources.AddOnnxSample((flags & 2) != 0 ? 0f : monoSample); 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 { for (uint f = 0; f < numFramesRead; f++) { genreAnalyzer.AddSample(0f); GenreSources.AddOnnxSample(0f); } currentTotalRms *= 0.7f; } captureClient.ReleaseBuffer(numFramesRead); hr = captureClient.GetNextPacketSize(out packetLength); } // A full second of consecutive failed capture iterations is persistent. if (hr != 0) { if (captureErrorSince < 0) captureErrorSince = lifecycleClock.ElapsedMilliseconds; if (lifecycleClock.ElapsedMilliseconds - captureErrorSince >= 1000) { Console.Error.WriteLine("Persistent WASAPI capture error: " + hr); Environment.Exit(5); } } else captureErrorSince = -1; // 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; } } } // Ancoraggio ai metadati: corregge gli errori 3+3+2 (4/3 e 4/5) // e le ottave usando il tempo reale del player. Se nessun candidato // rientra in tolleranza, bestCandidateBpm resta invariato (il metadato // puo' essere sbagliato: remix, edit) e l'audio resta la sorgente primaria. float anchorBpm; if (GenreSources.TryGetMetadataBpm(out anchorBpm)) { bestCandidateBpm = AnchorBpm(bestCandidateBpm, anchorBpm); } 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; } } } } } } } // Il folding ritmico e' valido solo sullo stesso brano. Il ring contiene envelope // di onset indipendenti dal tempo: il problema non e' il BPM in se', ma mediare // battute di brani diversi. Serve quindi un salto DISCRETO di tempo, non la deriva // dell'inseguimento morbido (0.94/0.06), che altrimenti azzererebbe lo storico // a ogni gradino e dembow resterebbe sempre a zero. // Il confronto usa il tempo canonico: un flip di ottava del rilevatore (170 -> 171) // non deve azzerare nulla, mentre un cambio reale di brano si'. double canonicalBpm = RuleGenreAnalyzer.CanonicalTempo(detectedBpm); if (detectedBpm > 50.0f && Math.Abs(canonicalBpm - foldedBpm) > 10.0) { genreAnalyzer.ResetRhythm(); } foldedBpm = canonicalBpm; genreAnalyzer.Publish(lifecycleClock.ElapsedMilliseconds, canonicalBpm, udpSend, remoteEp); // 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 } [ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IMMDeviceEnumerator { [PreserveSig] int EnumAudioEndpoints(EDataFlow dataFlow, int stateMask, out IntPtr devices); [PreserveSig] int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice endpoint); [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); } [ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IMMDevice { [PreserveSig] int Activate(ref Guid id, int clsCtx, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); [PreserveSig] int OpenPropertyStore(int stgmAccess, out IntPtr properties); [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetState(out int state); } [ComImport, Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioSessionManager2 { [PreserveSig] int GetAudioSessionControl(IntPtr sessionId, int priority, out IntPtr control); [PreserveSig] int GetSimpleAudioVolume(IntPtr sessionId, int priority, out IntPtr volume); [PreserveSig] int GetSessionEnumerator(out IAudioSessionEnumerator sessionEnum); } [ComImport, Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioSessionEnumerator { [PreserveSig] int GetCount(out int sessionCount); [PreserveSig] int GetSession(int sessionIndex, [MarshalAs(UnmanagedType.IUnknown)] out object session); } [ComImport, Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioSessionControl2 { [PreserveSig] int GetState(out int state); [PreserveSig] int GetDisplayName(out IntPtr name); [PreserveSig] int SetDisplayName([MarshalAs(UnmanagedType.LPWStr)] string value, IntPtr eventContext); [PreserveSig] int GetIconPath(out IntPtr path); [PreserveSig] int SetIconPath([MarshalAs(UnmanagedType.LPWStr)] string value, IntPtr eventContext); [PreserveSig] int GetGroupingParam(out Guid grouping); [PreserveSig] int SetGroupingParam(ref Guid grouping, IntPtr eventContext); [PreserveSig] int RegisterAudioSessionNotification(IntPtr client); [PreserveSig] int UnregisterAudioSessionNotification(IntPtr client); [PreserveSig] int GetSessionIdentifier(out IntPtr id); [PreserveSig] int GetSessionInstanceIdentifier(out IntPtr id); [PreserveSig] int GetProcessId(out uint processId); [PreserveSig] int IsSystemSoundsSession(); [PreserveSig] int SetDuckingPreference([MarshalAs(UnmanagedType.Bool)] bool optOut); } [ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioMeterInformation { [PreserveSig] 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; } [ComImport, Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioClient { [PreserveSig] int Initialize(int shareMode, int streamFlags, long hnsBufferDuration, long hnsPeriodicity, IntPtr pFormat, ref Guid audioSessionGuid); [PreserveSig] int GetBufferSize(out uint bufferFrameCount); [PreserveSig] int GetStreamLatency(out long hnsLatency); [PreserveSig] int GetCurrentPadding(out uint numPaddingFrames); [PreserveSig] int IsFormatSupported(int shareMode, IntPtr pFormat, out IntPtr ppClosestMatch); [PreserveSig] int GetMixFormat(out IntPtr ppDeviceFormat); [PreserveSig] int GetDevicePeriod(out long hnsDefaultDevicePeriod, out long hnsMinimumDevicePeriod); [PreserveSig] int Start(); [PreserveSig] int Stop(); [PreserveSig] int Reset(); [PreserveSig] int SetEventHandle(IntPtr eventHandle); [PreserveSig] int GetService(ref Guid interfaceId, [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); } [ComImport, Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] public interface IAudioCaptureClient { [PreserveSig] int GetBuffer(out IntPtr dataPointer, out uint numFramesToRead, out int bufferFlags, out ulong devicePosition, out ulong qpcPosition); [PreserveSig] int ReleaseBuffer(uint numFramesRead); [PreserveSig] int GetNextPacketSize(out uint numFramesInNextPacket); } }