add onnx and metadata
This commit is contained in:
1 parent
98d9fa24cf
commit
5f3f915258
18 files changed
+2229
-78
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user