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);
}
}
}
}