add onnx and metadata

This commit is contained in:
Overside srl committed 2026-11-04 18:10:32 +01:00
1 parent 98d9fa24cf
commit 5f3f915258
18 files changed
+2229 -78

No files matched your search

+425
View File
@@ -0,0 +1,425 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Windows.Media.Control;
namespace LoopbackCapture
{
// Only this background worker accesses GSMTC/network/cache. The audio thread reads
// one immutable snapshot, without locks, COM calls, network or waits.
sealed class GenreMetadataProvider
{
// Explicit mapping: unmapped genres (including Soundtrack) are not guessed.
static readonly Dictionary<string, string> GenreMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
{ "Latin", "latin" }, { "Reggaeton", "latin" }, { "Salsa", "latin" }, { "Bachata", "latin" },
{ "Electronic", "electronic" }, { "Dance", "electronic" }, { "House", "electronic" },
{ "Techno", "electronic" }, { "Trance", "electronic" },
{ "Hip-Hop/Rap", "hiphop" }, { "Rap", "hiphop" },
{ "Rock", "rock" }, { "Metal", "rock" }, { "Hard Rock", "rock" },
{ "Alternative", "rock" }, { "Punk", "rock" },
{ "Classical", "classical" }, { "Opera", "classical" }
};
const int PollMs = 5000, SnapshotTtlMs = 10000, WinRtTimeoutMs = 3000, CacheLimit = 512;
const long PositiveCacheMs = 86400000, NegativeCacheMs = 300000;
sealed class Snapshot
{
internal readonly string Label;
internal readonly string Detail;
internal readonly float Bpm;
internal readonly long Expires;
internal Snapshot(string label, string detail, float bpm, long expires) { Label = label; Detail = detail; Bpm = bpm; Expires = expires; }
}
sealed class CacheEntry
{
internal string Label;
internal string Detail;
internal float Bpm;
internal long Expires;
internal string ItunesStatus;
internal string DeezerStatus;
}
sealed class Track
{
internal GlobalSystemMediaTransportControlsSession Session;
internal string Artist, Title, Key;
}
// Lazy initialization stays inside the guarded worker, including HttpClient failures.
static class Http
{
internal static readonly HttpClient Client = Create();
static HttpClient Create()
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(3) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("ldcc/1.0");
return client;
}
}
readonly Dictionary<string, CacheEntry> cache = new Dictionary<string, CacheEntry>(StringComparer.Ordinal);
Snapshot current;
string currentKey;
// True when the genre from metadata is actually consumed (EnableMetadataGenre).
// When false the provider only resolves the BPM: iTunes is skipped, Deezer still runs.
readonly bool wantGenre;
// Diagnostic state is never sent over UDP; useful for read-only verification.
internal string LastStatus { get; private set; } = "starting";
// Per-source diagnostic state (concise strings, no spaces). Updated where the
// fact happens (Worker, Lookup), read by the DIAG: packet without locks: a
// reference assignment of an immutable string is atomic on .NET.
string metaStatus = "starting";
string itunesStatus = "skipped";
string deezerStatus = "skipped";
string trackText = "";
internal GenreMetadataProvider(bool wantGenre)
{
this.wantGenre = wantGenre;
if (!wantGenre)
{
metaStatus = "bpm-only";
itunesStatus = "off";
}
}
internal string MetaStatus { get { return Volatile.Read(ref metaStatus); } }
internal string ItunesStatus { get { return Volatile.Read(ref itunesStatus); } }
internal string DeezerStatus { get { return Volatile.Read(ref deezerStatus); } }
internal string TrackText { get { return Volatile.Read(ref trackText); } }
void SetMeta(string s) { Volatile.Write(ref metaStatus, s); }
void SetItunes(string s) { Volatile.Write(ref itunesStatus, s); }
void SetDeezer(string s) { Volatile.Write(ref deezerStatus, s); }
void SetTrack(string s) { Volatile.Write(ref trackText, s); }
internal void Start()
{
try
{
var thread = new Thread(Worker) { IsBackground = true, Name = "Genre metadata" };
thread.SetApartmentState(ApartmentState.MTA);
thread.Start();
}
catch { Clear("unavailable"); SetMeta("error"); }
}
internal string GetLabel()
{
try
{
Snapshot value = Volatile.Read(ref current);
return value != null && Environment.TickCount64 < value.Expires ? value.Label : null;
}
catch { return null; }
}
// Genere specifico del catalogo ("Hard Rock", "Hip-Hop/Rap"). Solo descrittivo:
// la classe grossolana resta il contratto per il gameplay.
internal string GetDetail()
{
try
{
Snapshot value = Volatile.Read(ref current);
return value != null && Environment.TickCount64 < value.Expires ? value.Detail : null;
}
catch { return null; }
}
internal bool TryGetBpm(out float bpm)
{
bpm = 0;
try
{
Snapshot value = Volatile.Read(ref current);
if (value == null || Environment.TickCount64 >= value.Expires) return false;
if (value.Bpm <= 0) return false;
bpm = value.Bpm;
return true;
}
catch { return false; }
}
void Clear(string status)
{
Volatile.Write(ref current, null);
currentKey = null;
LastStatus = status;
}
static T AwaitWinRt<T>(Task<T> task)
{
return task.WaitAsync(TimeSpan.FromMilliseconds(WinRtTimeoutMs)).GetAwaiter().GetResult();
}
static Track ReadPlayingTrack()
{
var manager = AwaitWinRt(GlobalSystemMediaTransportControlsSessionManager.RequestAsync().AsTask());
foreach (var session in manager.GetSessions())
{
try
{
if (session.GetPlaybackInfo()?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing) continue;
var media = AwaitWinRt(session.TryGetMediaPropertiesAsync().AsTask());
string artist = Normalize(media.Artist), title = Normalize(media.Title);
if (artist.Length == 0 || title.Length == 0) continue;
return new Track { Session = session, Artist = media.Artist, Title = media.Title, Key = artist + "\n" + title };
}
catch { }
}
return null;
}
void Worker()
{
// No async-void callback: every failure, including WinRT startup, is contained here.
while (true)
{
long start = Environment.TickCount64;
try
{
Track track = ReadPlayingTrack();
if (track == null)
{
Clear("no-playing-track");
SetMeta(wantGenre ? "no-track" : "bpm-only");
SetTrack("");
SetItunes(wantGenre ? "skipped" : "off");
SetDeezer("skipped");
}
else
{
SetTrack(SanitizeTrack(track.Artist + " - " + track.Title));
if (track.Key != currentKey) Clear("lookup");
currentKey = track.Key;
if (wantGenre) SetMeta("starting");
(string label, string detail, float bpm) = Lookup(track);
// A track may change while HTTP is in flight. Never publish that stale result.
var media = AwaitWinRt(track.Session.TryGetMediaPropertiesAsync().AsTask());
string key = Normalize(media.Artist) + "\n" + Normalize(media.Title);
if (key != track.Key || track.Session.GetPlaybackInfo()?.PlaybackStatus != GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing)
{
Clear("track-changed");
if (wantGenre) SetMeta("starting");
}
else if (wantGenre && label == null)
{
Clear("no-match-or-lookup-unavailable");
SetMeta("no-match");
}
else if (!wantGenre && bpm <= 0)
{
// BPM-only with no BPM: nothing to publish.
Clear("no-bpm");
}
else
{
currentKey = track.Key;
Volatile.Write(ref current, new Snapshot(label, detail, bpm, Environment.TickCount64 + SnapshotTtlMs));
LastStatus = wantGenre ? "metadata" : "bpm";
SetMeta(wantGenre ? "ok" : "bpm-only");
}
}
}
catch { Clear("unavailable"); SetMeta("error"); }
try { Thread.Sleep((int)Math.Max(1, PollMs - (Environment.TickCount64 - start))); }
catch { return; }
}
}
(string Label, string Detail, float Bpm) Lookup(Track track)
{
long now = Environment.TickCount64;
if (cache.TryGetValue(track.Key, out CacheEntry value) && now < value.Expires)
{
// Replay the per-source status captured at lookup time, so a negative
// cache does not hide "no-match"/"error" behind "skipped" for 5 minutes.
SetItunes(value.ItunesStatus ?? (wantGenre ? "skipped" : "off"));
SetDeezer(value.DeezerStatus ?? "skipped");
return (value.Label, value.Detail, value.Bpm);
}
string label = null, detail = null, itunesState;
if (wantGenre)
{
(label, detail, itunesState, _) = LookupItunesDetailed(track.Artist, track.Title);
}
else
{
// BPM-only: iTunes is not needed, skip the extra network call.
itunesState = "off";
}
var (bpm, deezerState, _) = LookupDeezerDetailed(track.Artist, track.Title);
SetItunes(itunesState);
SetDeezer(deezerState);
if (cache.Count >= CacheLimit) cache.Clear();
cache[track.Key] = new CacheEntry
{
Label = label,
Detail = detail,
Bpm = bpm,
Expires = now + (label == null ? NegativeCacheMs : PositiveCacheMs),
ItunesStatus = itunesState,
DeezerStatus = deezerState
};
return (label, detail, bpm);
}
// One iTunes lookup (search + genre mapping) with a per-source status and a
// human-readable reason. Used by both the worker (status only) and --check-sources.
internal static (string Label, string Detail, string Status, string Reason) LookupItunesDetailed(string artist, string title)
{
try
{
string url = "https://itunes.apple.com/search?term=" + Uri.EscapeDataString(artist + " " + title) + "&entity=song&limit=5&country=IT";
string json = Http.Client.GetStringAsync(url).GetAwaiter().GetResult();
(string label, string detail) = MatchResponse(json, artist, title);
if (label == null) return (null, null, "no-match", "nessun match iTunes (artista/titolo non corrispondenti o genere non mappato)");
return (label, detail, "ok", "match");
}
catch (Exception ex) { return (null, null, "error", ClassifyHttp(ex)); }
}
// One Deezer lookup (search -> track id -> bpm) with status and reason.
internal static (float Bpm, string Status, string Reason) LookupDeezerDetailed(string artist, string title)
{
try
{
string searchUrl = "https://api.deezer.com/search?q=" + Uri.EscapeDataString(artist + " " + title) + "&limit=1";
string searchJson = Http.Client.GetStringAsync(searchUrl).GetAwaiter().GetResult();
long id = DeezerSearchId(searchJson);
if (id <= 0) return (0, "no-match", "nessun risultato di ricerca Deezer");
string trackUrl = "https://api.deezer.com/track/" + id;
string trackJson = Http.Client.GetStringAsync(trackUrl).GetAwaiter().GetResult();
(float bpm, string status) = DeezerTrackBpmStatus(trackJson, artist, title);
return (bpm, status, status == "ok" ? "match" : status == "no-bpm" ? "BPM assente" : "artista/titolo non corrispondenti");
}
catch (Exception ex) { return (0, "error", ClassifyHttp(ex)); }
}
// Exact reason for a failed lookup: HTTP status, timeout or invalid JSON.
static string ClassifyHttp(Exception ex)
{
if (ex is HttpRequestException hre && hre.StatusCode != null) return "HTTP " + (int)hre.StatusCode;
if (ex is TaskCanceledException) return "timeout (3s)";
if (ex is JsonException) return "JSON invalido";
return ex.GetType().Name + ": " + ex.Message;
}
// GSMTC track for --check-sources. Null when no session is playing or GSMTC fails.
internal static (string Artist, string Title) ReadCurrentTrack()
{
try
{
Track track = ReadPlayingTrack();
return track == null ? (null, null) : (track.Artist, track.Title);
}
catch { return (null, null); }
}
static string SanitizeTrack(string value)
{
if (string.IsNullOrEmpty(value)) return "";
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();
}
static long DeezerSearchId(string json)
{
using (JsonDocument document = JsonDocument.Parse(json))
{
if (!document.RootElement.TryGetProperty("data", out JsonElement data) || data.ValueKind != JsonValueKind.Array) return 0;
foreach (JsonElement item in data.EnumerateArray())
{
if (item.TryGetProperty("id", out JsonElement id) && id.ValueKind == JsonValueKind.Number)
return id.GetInt64();
}
}
return 0;
}
// Distinguishes "no match" (artist/title differ) from "no bpm" (matched but
// the field is absent or zero), which the DIAG states expose separately.
static (float Bpm, string Status) DeezerTrackBpmStatus(string json, string artist, string title)
{
using (JsonDocument document = JsonDocument.Parse(json))
{
JsonElement root = document.RootElement;
if (!root.TryGetProperty("title", out JsonElement t) || t.ValueKind != JsonValueKind.String) return (0, "no-match");
string foundTitle = t.GetString();
if (!root.TryGetProperty("artist", out JsonElement art) || art.ValueKind != JsonValueKind.Object) return (0, "no-match");
if (!art.TryGetProperty("name", out JsonElement an) || an.ValueKind != JsonValueKind.String) return (0, "no-match");
string foundArtist = an.GetString();
if (!MatchesTrack(foundArtist, foundTitle, artist, title)) return (0, "no-match");
if (!root.TryGetProperty("bpm", out JsonElement bpm) || bpm.ValueKind != JsonValueKind.Number) return (0, "no-bpm");
float v = bpm.GetSingle();
return (v > 0 ? v : 0, v > 0 ? "ok" : "no-bpm");
}
}
// Same normalization/word-boundary match used for iTunes: artist and title must
// both correspond, otherwise the Deezer result is discarded.
static bool MatchesTrack(string foundArtist, string foundTitle, string wantedArtist, string wantedTitle)
{
string fA = Normalize(foundArtist), fT = Normalize(foundTitle);
string wA = Normalize(wantedArtist), wT = Normalize(wantedTitle);
if (fA.Length == 0 || fT.Length == 0 || wA.Length == 0 || wT.Length == 0) return false;
if (fT != wT) return false;
if (!(" " + fA + " ").Contains(" " + wA + " ", StringComparison.Ordinal) &&
!(" " + wA + " ").Contains(" " + fA + " ", StringComparison.Ordinal)) return false;
return true;
}
internal static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
// Remove parenthesized edition qualifiers; punctuation becomes whitespace.
value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " ").ToLowerInvariant();
var text = new StringBuilder();
bool space = false;
foreach (char c in value)
{
if (char.IsLetterOrDigit(c)) { if (space && text.Length > 0) text.Append(' '); text.Append(c); space = false; }
else space = true;
}
return text.ToString();
}
internal static (string Label, string Detail) MatchResponse(string json, string artist, string title)
{
string wantedArtist = Normalize(artist), wantedTitle = Normalize(title);
if (wantedArtist.Length == 0 || wantedTitle.Length == 0) return (null, null);
using (JsonDocument document = JsonDocument.Parse(json))
{
if (!document.RootElement.TryGetProperty("results", out JsonElement results) || results.ValueKind != JsonValueKind.Array) return (null, null);
string match = null, detail = null;
foreach (JsonElement item in results.EnumerateArray())
{
if (!item.TryGetProperty("artistName", out JsonElement a) || a.ValueKind != JsonValueKind.String ||
!item.TryGetProperty("trackName", out JsonElement t) || t.ValueKind != JsonValueKind.String ||
!item.TryGetProperty("primaryGenreName", out JsonElement g) || g.ValueKind != JsonValueKind.String) continue;
string foundArtist = Normalize(a.GetString()), foundTitle = Normalize(t.GetString());
// Word boundaries avoid e.g. "Cher" matching "Cherry". Title must also match.
if (foundArtist.Length == 0 || foundTitle != wantedTitle) continue;
if (!(" " + foundArtist + " ").Contains(" " + wantedArtist + " ", StringComparison.Ordinal) &&
!(" " + wantedArtist + " ").Contains(" " + foundArtist + " ", StringComparison.Ordinal)) continue;
string raw = g.GetString().Trim();
if (!GenreMap.TryGetValue(raw, out string label)) continue;
if (match != null && match != label) return (null, null); // Ambiguous editions: prefer rules.
match = label;
if (detail == null) detail = raw; // Genere specifico del catalogo, es. "Hard Rock".
}
return (match, detail);
}
}
}
}
+346
View File
@@ -0,0 +1,346 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
namespace LoopbackCapture
{
// Immutable publication; never expose worker-owned/reused inference buffers.
sealed class GenrePrediction
{
internal readonly string Label, Source;
// Genere specifico dal catalogo ("Hard Rock"), vuoto per regole/ONNX.
internal readonly string Detail;
internal readonly float[] Probabilities;
internal readonly long ExpiresMs;
internal float Confidence { get { return Probabilities.Max(); } }
internal GenrePrediction(float[] probabilities, string source, long expires, string detail = null)
{
Probabilities = probabilities; Source = source; ExpiresMs = expires; Detail = detail;
int best = 0;
for (int i = 1; i < probabilities.Length; i++) if (probabilities[i] > probabilities[best]) best = i;
Label = probabilities[best] < 0.30f ? "unknown" : GenreOnnxProvider.Labels[best];
}
}
sealed class GenreOnnxProvider
{
internal static readonly string[] Labels = { "latin", "electronic", "hiphop", "rock", "classical" };
internal const int FftSize = 2048, MelBands = 128, FrameCount = 300;
const int InferenceMs = 1000, ResultTtlMs = 3000;
readonly int rate;
readonly string dumpPath;
readonly ManualResetEventSlim initialized = new ManualResetEventSlim(false);
MelFrontend frontend;
GenrePrediction prediction;
int disabled, dumpWritten;
string status = "warming";
string failureReason = "";
internal bool Failed { get { return Volatile.Read(ref disabled) != 0; } }
internal bool DumpWritten { get { return Volatile.Read(ref dumpWritten) != 0; } }
// Diagnostic status: off | no-model | warming | ok | failed. Read by the DIAG:
// packet without locks (string reference assignment is atomic).
internal string Status { get { return Volatile.Read(ref status); } }
internal string FailureReason { get { return Volatile.Read(ref failureReason); } }
void SetStatus(string s) { Volatile.Write(ref status, s); }
void SetFailure(string s) { Volatile.Write(ref failureReason, s); }
internal GenreOnnxProvider(int sampleRate, string dump)
{ rate = sampleRate; dumpPath = dump; }
internal void Start()
{
try { new Thread(Worker) { IsBackground = true, Name = "Genre ONNX" }.Start(); }
catch (Exception ex) { Disable(ex); initialized.Set(); }
}
void Disable(Exception ex)
{
Volatile.Write(ref prediction, null);
if (Interlocked.Exchange(ref disabled, 1) == 0)
{
SetFailure(ex.Message);
SetStatus("failed");
Console.Error.WriteLine("ONNX disabled for this session: " + ex.Message +
(dumpPath != null && !DumpWritten ? " (tensor dump rejected: no valid tensor written)" : ""));
}
}
internal bool WaitForInitialization(int milliseconds)
{ return initialized.Wait(milliseconds) && !Failed && Volatile.Read(ref frontend) != null; }
// Called on audio thread: preallocated math only. No locks, I/O, or waits.
internal void AddSample(float sample)
{
if (Failed) return;
try { Volatile.Read(ref frontend)?.AddSample(sample); }
catch (Exception ex) { Disable(ex); }
}
internal GenrePrediction GetPrediction()
{
if (Failed) return null;
GenrePrediction value = Volatile.Read(ref prediction);
return value != null && Environment.TickCount64 < value.ExpiresMs ? value : null;
}
void Worker()
{
try
{
string modelPath = Path.Combine(AppContext.BaseDirectory, "genre_model.onnx");
string jsonPath = Path.Combine(AppContext.BaseDirectory, "genre_model.json");
if (!File.Exists(modelPath) || !File.Exists(jsonPath))
{
// Permanent: the model is absent, not a transient load failure.
SetFailure("genre_model.onnx / genre_model.json missing");
SetStatus("no-model");
Volatile.Write(ref disabled, 1);
return;
}
using var json = JsonDocument.Parse(File.ReadAllText(jsonPath));
var root = json.RootElement;
if (root.GetProperty("version").GetInt32() != 1 ||
!root.GetProperty("labels").EnumerateArray().Select(x => x.GetString()).SequenceEqual(Labels))
throw new InvalidDataException("Unsupported model version or label order");
double mean = root.GetProperty("mean").GetDouble(), std = root.GetProperty("std").GetDouble();
if (!double.IsFinite(mean) || !double.IsFinite(std) || std <= 0) throw new InvalidDataException("Invalid model mean/std");
using var options = new SessionOptions { IntraOpNumThreads = 1, InterOpNumThreads = 1, ExecutionMode = ExecutionMode.ORT_SEQUENTIAL };
using var session = new InferenceSession(modelPath, options);
if (session.InputMetadata.Count != 1 || session.OutputMetadata.Count != 1) throw new InvalidDataException("Expected one model input and output");
var input = session.InputMetadata.Single(); var output = session.OutputMetadata.Single();
if (input.Value.ElementType != typeof(float) || !input.Value.Dimensions.SequenceEqual(new[] { 1, 1, MelBands, FrameCount }) ||
output.Value.ElementType != typeof(float) || !output.Value.Dimensions.SequenceEqual(new[] { 1, 5 }))
throw new InvalidDataException("Expected float32 [1,1,128,300] -> [1,5]");
var localFrontend = new MelFrontend(rate, mean, std);
var snapshot = new float[MelBands * FrameCount];
var tensor = new DenseTensor<float>(snapshot, new[] { 1, 1, MelBands, FrameCount });
var inputs = new[] { NamedOnnxValue.CreateFromTensor(input.Key, tensor) };
Volatile.Write(ref frontend, localFrontend);
initialized.Set();
SetStatus("warming");
long next = Environment.TickCount64, lastFrame = -1;
while (!Failed)
{
long wait = next - Environment.TickCount64;
if (wait > 0) Thread.Sleep((int)Math.Min(wait, InferenceMs)); // Worker only.
if (Failed) break;
next += InferenceMs;
if (localFrontend.TrySnapshot(snapshot, out long frame) && frame != lastFrame)
{
lastFrame = frame;
if (dumpPath != null && !DumpWritten)
{
// BinaryWriter writes IEEE float32 little-endian; exactly 153600 bytes.
// Write atomically: no empty/partial target if initialization or I/O fails.
string temporary = dumpPath + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
using (var writer = new BinaryWriter(File.Open(temporary, FileMode.CreateNew)))
foreach (float value in snapshot) writer.Write(value);
File.Move(temporary, dumpPath, true);
Volatile.Write(ref dumpWritten, 1);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
}
using var result = session.Run(inputs);
float[] probabilities = result.Single().AsTensor<float>().ToArray();
double sum = probabilities.Sum(x => (double)x);
if (probabilities.Length != 5 || probabilities.Any(x => !float.IsFinite(x) || x < 0 || x > 1) || Math.Abs(sum - 1) > 0.001)
throw new InvalidDataException("Model output is not five probabilities summing to one");
if (!Failed) Volatile.Write(ref prediction, new GenrePrediction(probabilities, "onnx", Environment.TickCount64 + ResultTtlMs));
if (!Failed) SetStatus("ok");
}
// Skip missed slots. Never accumulate work when Run exceeds one second.
long now = Environment.TickCount64;
if (next <= now) next += ((now - next) / InferenceMs + 1) * InferenceMs;
}
}
catch (Exception ex) { Disable(ex); }
finally { initialized.Set(); }
}
sealed class MelFrontend
{
readonly int hop;
readonly double mean, std;
readonly double[] pcm = new double[FftSize], window = new double[FftSize];
readonly double[] real = new double[FftSize], imag = new double[FftSize], power = new double[FftSize / 2 + 1];
readonly double[] cos = new double[FftSize / 2], sin = new double[FftSize / 2];
readonly int[] reverse = new int[FftSize], starts = new int[MelBands];
readonly double[][] weights = new double[MelBands][];
readonly float[] ring = new float[MelBands * FrameCount];
int write, pending, sequence;
long frames;
internal MelFrontend(int rate, double mean, double std)
{
if (rate != 44100 && rate != 48000) throw new NotSupportedException("ONNX front-end supports 44100/48000 Hz");
hop = rate / 100; this.mean = mean; this.std = std;
for (int i = 0; i < FftSize; i++)
{
window[i] = 0.5 - 0.5 * Math.Cos(2 * Math.PI * i / FftSize); // PERIODIC, independent of rules.
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); }
double low = 2595 * Math.Log10(1 + 30.0 / 700), high = 2595 * Math.Log10(1 + 11000.0 / 700);
var edges = new double[MelBands + 2];
for (int i = 0; i < edges.Length; i++) edges[i] = 700 * (Math.Pow(10, (low + (high - low) * i / (MelBands + 1)) / 2595) - 1) * FftSize / rate;
for (int m = 0; m < MelBands; m++)
{
double left = edges[m], center = edges[m + 1], right = edges[m + 2];
starts[m] = Math.Max(0, (int)Math.Ceiling(left));
int end = Math.Min(FftSize / 2, (int)Math.Floor(right));
weights[m] = new double[Math.Max(0, end - starts[m] + 1)];
for (int j = 0; j < weights[m].Length; j++)
{
int k = starts[m] + j;
// Fractional edges; divide by FULL support width in bins (not half-area).
weights[m][j] = Math.Max(0, Math.Min((k - left) / (center - left), (right - k) / (right - center))) / (right - left);
}
}
}
internal void AddSample(float sample)
{
if (!float.IsFinite(sample)) throw new InvalidDataException("Non-finite PCM");
pcm[write] = sample; write = (write + 1) % FftSize;
if (++pending < hop) return;
pending = 0;
// The initial zeroed ring supplies exactly N-hop left padding.
for (int i = 0; i < FftSize; i++) { real[reverse[i]] = pcm[(write + i) % FftSize] * window[i]; imag[reverse[i]] = 0; }
for (int n = 2; n <= FftSize; n <<= 1)
for (int start = 0; start < FftSize; start += n)
for (int j = 0; j < n / 2; j++)
{
int a = start + j, b = a + n / 2, t = j * (FftSize / n);
double tr = cos[t] * real[b] - sin[t] * imag[b], 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;
}
for (int k = 0; k < power.Length; k++) power[k] = real[k] * real[k] + imag[k] * imag[k];
Interlocked.Increment(ref sequence); // odd: producer updating frame. Never waits.
int column = (int)(frames % FrameCount);
for (int m = 0; m < MelBands; m++)
{
double energy = 0;
for (int j = 0; j < weights[m].Length; j++) energy += weights[m][j] * power[starts[m] + j];
ring[m * FrameCount + column] = (float)((Math.Log10(Math.Max(energy, 1e-10)) - mean) / std);
}
frames++;
Interlocked.Increment(ref sequence); // even: snapshot available.
}
internal bool TrySnapshot(float[] target, out long frame)
{
// Seqlock: only the worker retries; audio producer is never locked or blocked.
frame = 0;
for (int attempt = 0; attempt < 3; attempt++)
{
int before = Volatile.Read(ref sequence);
if ((before & 1) != 0) continue;
long count = Interlocked.Read(ref frames);
if (count < FrameCount) return false;
int start = (int)(count % FrameCount);
for (int m = 0; m < MelBands; m++)
{
Array.Copy(ring, m * FrameCount + start, target, m * FrameCount, FrameCount - start);
Array.Copy(ring, m * FrameCount, target, m * FrameCount + FrameCount - start, start);
}
Thread.MemoryBarrier();
if (before == Volatile.Read(ref sequence)) { frame = count; return true; }
}
return false;
}
}
// Diagnostic input bypasses WASAPI, but uses the SAME frontend/session worker.
// --rate reinterprets samples (no resampling), taking the first 300*hop samples.
internal static int VerifyWav(string path, int forcedRate, string dump, int port)
{
try
{
float[] pcm = ReadWav(path, out int rate);
if (forcedRate != 0) rate = forcedRate;
int count = (rate / 100) * FrameCount;
if (pcm.Length < count) throw new InvalidDataException("Fixture too short for 300 frames at requested rate");
var provider = new GenreOnnxProvider(rate, dump);
provider.Start();
if (!provider.WaitForInitialization(30000))
{
if (!provider.Failed) provider.Disable(new TimeoutException("ONNX initialization timed out"));
return 5;
}
for (int i = 0; i < count; i++) provider.AddSample(pcm[i]);
var clock = Stopwatch.StartNew();
while (!provider.Failed && (provider.GetPrediction() == null || (dump != null && !provider.DumpWritten)) && clock.ElapsedMilliseconds < 30000) Thread.Sleep(10);
if (provider.Failed) return 5;
var result = provider.GetPrediction();
if (result == null) throw new TimeoutException("ONNX verification inference timed out");
var message = new StringBuilder();
message.AppendFormat(System.Globalization.CultureInfo.InvariantCulture, "GENR:2;{0};{1:F3};onnx", result.Label, result.Confidence);
for (int i = 0; i < 5; i++) message.AppendFormat(System.Globalization.CultureInfo.InvariantCulture, ";{0}={1:F3}", Labels[i], result.Probabilities[i]);
using var udp = new UdpClient();
byte[] bytes = Encoding.UTF8.GetBytes(message.ToString());
udp.Send(bytes, bytes.Length, new IPEndPoint(IPAddress.Loopback, port));
return 0;
}
catch (Exception ex) { Console.Error.WriteLine("ONNX verification rejected: " + ex.Message); return 5; }
}
static float[] ReadWav(string path, out int rate)
{
using var reader = new BinaryReader(File.OpenRead(path));
if (new string(reader.ReadChars(4)) != "RIFF") throw new InvalidDataException("Expected RIFF WAV");
reader.ReadUInt32();
if (new string(reader.ReadChars(4)) != "WAVE") throw new InvalidDataException("Expected WAVE");
int format = 0, channels = 0, bits = 0, align = 0; rate = 0; byte[] data = null;
while (reader.BaseStream.Position + 8 <= reader.BaseStream.Length)
{
string id = new string(reader.ReadChars(4)); uint size = reader.ReadUInt32();
long next = reader.BaseStream.Position + size + (size & 1);
if (next > reader.BaseStream.Length + 1) throw new InvalidDataException("Truncated WAV");
if (id == "fmt ")
{
if (size < 16) throw new InvalidDataException("Invalid WAV fmt");
format = reader.ReadUInt16(); channels = reader.ReadUInt16(); rate = reader.ReadInt32();
reader.ReadUInt32(); align = reader.ReadUInt16(); bits = reader.ReadUInt16();
if (format == 65534 && size >= 40) { reader.ReadUInt16(); reader.ReadUInt16(); reader.ReadUInt32(); format = reader.ReadUInt16(); }
}
else if (id == "data") data = reader.ReadBytes(checked((int)size));
reader.BaseStream.Position = next;
}
if (data == null || channels < 1 || (format != 1 && format != 3) || align != channels * (bits / 8) || align == 0 || data.Length % align != 0)
throw new InvalidDataException("Unsupported WAV format");
var samples = new float[data.Length / align];
for (int i = 0; i < samples.Length; i++)
{
double sum = 0;
for (int c = 0; c < channels; c++)
{
int offset = i * align + c * (bits / 8);
if (format == 3 && bits == 32) sum += BitConverter.ToSingle(data, offset);
else if (format == 3 && bits == 64) sum += BitConverter.ToDouble(data, offset);
else if (format == 1 && bits == 8) sum += (data[offset] - 128) / 128.0;
else if (format == 1 && bits == 16) sum += BitConverter.ToInt16(data, offset) / 32768.0;
else if (format == 1 && bits == 24)
{ int v = data[offset] | data[offset + 1] << 8 | data[offset + 2] << 16; if ((v & 0x800000) != 0) v |= unchecked((int)0xff000000); sum += v / 8388608.0; }
else if (format == 1 && bits == 32) sum += BitConverter.ToInt32(data, offset) / 2147483648.0;
else throw new InvalidDataException("Unsupported WAV sample encoding");
}
samples[i] = (float)(sum / channels);
}
return samples;
}
}
}
+484 -57
View File
@@ -15,6 +15,145 @@ 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
{
@@ -227,8 +366,18 @@ namespace LoopbackCapture
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); }
void RhythmFeatures(double bpm)
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++)
{
@@ -367,9 +516,18 @@ namespace LoopbackCapture
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:1;{0};{1:F3}", label, probabilities[best]);
for (int c = 0; c < Labels.Length; c++) packet.AppendFormat(CultureInfo.InvariantCulture, ";{0}={1:F3}", Labels[c], probabilities[c]);
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)
{
@@ -402,6 +560,202 @@ namespace LoopbackCapture
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 <anchor>,<detected1>,... (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<string>();
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
@@ -420,6 +774,44 @@ namespace LoopbackCapture
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))
@@ -434,12 +826,18 @@ namespace LoopbackCapture
Interlocked.Exchange(ref leaseDeadlineMs, lifecycleClock.ElapsedMilliseconds + leaseMs);
int port = 4242;
int cmdPort = 4243;
if (args.Length > 0)
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)
@@ -468,6 +866,7 @@ namespace LoopbackCapture
Environment.Exit(3);
}
GC.KeepAlive(instanceMutex);
try { GenreSources.Start(); } catch { }
UdpClient udpSend = null;
try
@@ -599,6 +998,7 @@ namespace LoopbackCapture
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;
@@ -621,6 +1021,7 @@ namespace LoopbackCapture
long lastHeartbeat = -250;
long heartbeatCounter = 0;
long captureErrorSince = -1;
long lastDiagBroadcast = -5000;
while (true)
{
@@ -655,6 +1056,17 @@ namespace LoopbackCapture
}
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);
@@ -702,6 +1114,7 @@ namespace LoopbackCapture
}
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
@@ -745,7 +1158,11 @@ namespace LoopbackCapture
}
else
{
for (uint f = 0; f < numFramesRead; f++) genreAnalyzer.AddSample(0f);
for (uint f = 0; f < numFramesRead; f++)
{
genreAnalyzer.AddSample(0f);
GenreSources.AddOnnxSample(0f);
}
currentTotalRms *= 0.7f;
}
@@ -895,6 +1312,16 @@ namespace LoopbackCapture
}
}
// 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)
@@ -1030,63 +1457,63 @@ namespace LoopbackCapture
public enum EDataFlow { eRender, eCapture, eAll }
public enum ERole { eConsole, eMultimedia, eCommunications }
[Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
[Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
[Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
[Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[PreserveSig] int GetCount(out int sessionCount);
[PreserveSig] int GetSession(int sessionIndex, [MarshalAs(UnmanagedType.IUnknown)] out object session);
}
[Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
[Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface IAudioMeterInformation
{
int GetPeakValue(out float peak);
[PreserveSig] int GetPeakValue(out float peak);
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
@@ -1110,28 +1537,28 @@ namespace LoopbackCapture
public Guid SubFormat;
}
[Guid("1CB9AD4C-DBFA-4c32-B178-C2F568A703B2"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
[Guid("C8ADBD64-E71E-48a0-A4DE-185C395CD317"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
[ComImport, 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);
[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);
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<OutputType>WinExe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<InvariantGlobalization>true</InvariantGlobalization>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Compile Include="LoopbackCapture.cs" />
<Compile Include="GenreMetadataProvider.cs" />
<Compile Include="GenreOnnxProvider.cs" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />
</ItemGroup>
</Project>
Binary file not shown.
+34 -9
View File
@@ -1,10 +1,35 @@
$csc = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe"
if (-not (Test-Path $csc)) {
$csc = "C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe"
$ErrorActionPreference = 'Stop'
$work = Join-Path ([System.IO.Path]::GetTempPath()) ('ldcc-publish-' + [Guid]::NewGuid().ToString('N'))
$exitCode = 1
try {
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw 'dotnet SDK non trovato. Installare un SDK compatibile con .NET 8.'
}
$publish = Join-Path $work 'publish'
$obj = (Join-Path $work 'obj') + '\'
$output = (Join-Path $work 'build') + '\'
& dotnet publish (Join-Path $PSScriptRoot 'LoopbackCapture.csproj') -c Release -o $publish "-p:BaseIntermediateOutputPath=$obj" "-p:BaseOutputPath=$output"
$exitCode = $LASTEXITCODE
if ($exitCode -eq 0) {
try {
$built = Get-Item (Join-Path $publish 'LoopbackCapture.exe')
# Guardia: un self-contained single-file pesa decine di MB. Poche centinaia di
# KB significano publish framework-dependent, che su un PC senza runtime .NET
# non parte. Meglio fallire qui che distribuire un exe che non si avvia.
if ($built.Length -lt 20MB) {
throw "publish inatteso: $($built.Length) byte. Atteso self-contained (>20 MB): controllare SelfContained/RuntimeIdentifier/PublishSingleFile in LoopbackCapture.csproj."
}
Copy-Item $built.FullName (Join-Path $PSScriptRoot 'LoopbackCapture.exe') -Force
Write-Output ("Build completata: bin\LoopbackCapture.exe ({0:N0} byte, self-contained .NET 8)." -f $built.Length)
} catch {
$exitCode = 1
Write-Error "Impossibile aggiornare bin\LoopbackCapture.exe: file in uso o accesso negato. Chiudere il bridge e ripetere la build. $($_.Exception.Message)" -ErrorAction Continue
}
}
} catch {
$exitCode = 1
Write-Error $_ -ErrorAction Continue
} finally {
if (Test-Path $work) { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue }
}
if (-not (Test-Path $csc)) {
Write-Error "Compilatore .NET Framework csc.exe non trovato (Framework64 / Framework)."
exit 1
}
& $csc /nologo /unsafe /optimize+ /target:exe /out:bin\LoopbackCapture.exe bin\LoopbackCapture.cs
exit $LASTEXITCODE
exit $exitCode
+26
View File
@@ -0,0 +1,26 @@
{
"labels": [
"latin",
"electronic",
"hiphop",
"rock",
"classical"
],
"mean": -4.0,
"std": 4.0,
"version": 1,
"source": "designed",
"frontend": {
"fft": 2048,
"hann": "periodic",
"hop": "sr//100",
"padding": "causal-left-N-hop",
"power": "abs(rfft)^2",
"mel": "HTK",
"bands": 128,
"fmin": 30,
"fmax": 11000,
"area": "divide by full support width in fractional bins",
"log_floor": 1e-10
}
}
Binary file not shown.