diff --git a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs index 877b8d7..b74784e 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper.Tests/TestAudioFingerprinting.cs @@ -2,6 +2,8 @@ * which supports both chromaprint and the "-fp_format raw" flag. */ +using System.Collections.Generic; +using System.Collections.ObjectModel; using Xunit; using Microsoft.Extensions.Logging; @@ -61,6 +63,26 @@ public class TestAudioFingerprinting Assert.Equal(expected, actual); } + [Fact] + public void TestIndexGeneration() + { + // 0 1 2 3 4 5 6 7 + var fpr = new List(new uint[] { 1, 2, 3, 1, 5, 77, 42, 2 }).AsReadOnly(); + var expected = new Dictionary>() + { + {1, new Collection{ 0, 3 } }, + {2, new Collection{ 1, 7 } }, + {3, new Collection{ 2 } }, + {5, new Collection{ 4 } }, + {42, new Collection{ 6 } }, + {77, new Collection{ 5 } }, + }; + + var actual = Chromaprint.CreateInvertedIndex(fpr); + + Assert.Equal(expected, actual); + } + [Fact] public void TestIntroDetection() { @@ -76,8 +98,8 @@ public class TestAudioFingerprinting Assert.Equal(17.792, lhs.IntroEnd); Assert.True(rhs.Valid); - Assert.Equal(0, rhs.IntroStart); - Assert.Equal(22.656, rhs.IntroEnd); + Assert.Equal(5.12, rhs.IntroStart); + Assert.Equal(22.912, rhs.IntroEnd); } private QueuedEpisode queueEpisode(string path) diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs index 950679a..e417dce 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Chromaprint.cs @@ -109,6 +109,30 @@ public static class Chromaprint return results.AsReadOnly(); } + /// + /// Transforms a Chromaprint into an inverted index of fingerprint points to the indexes they appeared at. + /// + /// Chromaprint fingerprint. + /// Inverted index. + public static Dictionary> CreateInvertedIndex(ReadOnlyCollection fingerprint) + { + var invIndex = new Dictionary>(); + + for (int i = 0; i < fingerprint.Count; i++) + { + // Get the current point. + var point = fingerprint[i]; + + // Create a new collection for points of this value if it doesn't exist already. + invIndex.TryAdd(point, new Collection()); + + // Append the current sample's timecode to the collection for this point. + invIndex[point].Add((uint)i); + } + + return invIndex; + } + /// /// Runs ffmpeg and returns standard output. /// diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html index 1666214..b8dadf7 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Configuration/configPage.html @@ -106,7 +106,7 @@
- Analysis Statistics + Analysis Statistics (experimental)

@@ -239,7 +239,7 @@ // Blank any old statistics const text = document.querySelector("pre#statisticsText"); - text.textContent = ""; + text.textContent = "All CPU times are displayed as seconds.\n\n"; Dashboard.showLoadingMsg(); @@ -247,14 +247,15 @@ let stats = await getJson("Intros/Statistics"); // Select which fields to print and label them with more friendly descriptions - let fields = "TotalCPUTime,FingerprintCPUTime,FirstPassCPUTime,SecondPassCPUTime,QuickScans,FullScans," + - "TotalQueuedEpisodes"; + let fields = "TotalCPUTime,FingerprintCPUTime,FirstPassCPUTime,SecondPassCPUTime,IndexSearches," + + "QuickScans,FullScans,TotalQueuedEpisodes"; let friendlyNames = { - TotalCPUTime: "Total CPU time (ms)", - FingerprintCPUTime: "Fingerprint time (ms)", - FirstPassCPUTime: "First pass (ms)", - SecondPassCPUTime: "Second pass (ms)", + TotalCPUTime: "Total CPU time", + FingerprintCPUTime: "Fingerprint CPU time", + FirstPassCPUTime: "First pass CPU time", + SecondPassCPUTime: "Second pass CPU time", + IndexSearches: "Index searches", QuickScans: "Quick scans", FullScans: "Full scans", TotalQueuedEpisodes: "Episodes queued", @@ -262,9 +263,25 @@ // Print all friendly names and data points for (var f of fields.split(",")) { - text.textContent += friendlyNames[f].padEnd(25) + stats[f] + "\n"; + let name = friendlyNames[f].padEnd(25); + let value = stats[f]; + + // If this statistic is a measure of CPU time, divide by 1,000 to turn milliseconds into seconds. + if (name.includes("time")) + { + value = Math.round(value / 1000); + } + + text.textContent += name + value + "\n"; } + // Calculate the percentage of time (to two decimal places) spent waiting for fingerprints + const percentWait = Math.round((stats.FingerprintCPUTime * 10_000) / stats.TotalCPUTime) / 100; + + // Breakdown CPU time by analysis component + text.textContent += "\nCPU time breakdown:\n"; + text.textContent += "Fingerprint generation " + percentWait + "%\n"; + Dashboard.hideLoadingMsg(); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs index 8ca0e27..d0f1e97 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/Data/AnalysisStatistics.cs @@ -20,6 +20,11 @@ public class AnalysisStatistics /// public int TotalQueuedEpisodes { get; set; } + /// + /// Gets the number of times an index search successfully located a pair of introductions. + /// + public ThreadSafeInteger IndexSearches { get; } = new ThreadSafeInteger(); + /// /// Gets the number of times a quick scan successfully located a pair of introductions. /// @@ -89,6 +94,11 @@ public class ThreadSafeInteger /// Start time. public void AddDuration(DateTime start) { + if (start == DateTime.MinValue) + { + return; + } + var elapsed = DateTime.Now.Subtract(start); Add((int)elapsed.TotalMilliseconds); } diff --git a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs index 3598997..ed91580 100644 --- a/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs +++ b/ConfusedPolarBear.Plugin.IntroSkipper/ScheduledTasks/FingerprinterTask.cs @@ -392,7 +392,8 @@ public class FingerprinterTask : IScheduledTask lhsEpisode.EpisodeId, lhsFingerprint, rhsEpisode.EpisodeId, - rhsFingerprint); + rhsFingerprint, + true); } /// @@ -402,53 +403,86 @@ public class FingerprinterTask : IScheduledTask /// First episode fingerprint points. /// Second episode id. /// Second episode fingerprint points. + /// If this was called as part of the first analysis pass, add the elapsed time to the statistics. /// Intros for the first and second episodes. public (Intro Lhs, Intro Rhs) FingerprintEpisodes( Guid lhsId, ReadOnlyCollection lhsPoints, Guid rhsId, - ReadOnlyCollection rhsPoints) + ReadOnlyCollection rhsPoints, + bool isFirstPass) { - var start = DateTime.Now; - var lhsRanges = new List(); - var rhsRanges = new List(); + // If this isn't running as part of the first analysis pass, don't count this CPU time as first pass time. + var start = isFirstPass ? DateTime.Now : DateTime.MinValue; - // Compare all elements of the shortest fingerprint to the other fingerprint. - var limit = Math.Min(lhsPoints.Count, rhsPoints.Count); + // ===== Method 1: Inverted indexes ===== + // Creates an inverted fingerprint point index for both episodes. + // For every point which is a 100% match, search for an introduction at that point. + var (lhsRanges, rhsRanges) = SearchInvertedIndex(lhsPoints, rhsPoints); - // First, test if an intro can be found within the first 5 seconds of the episodes (±5/0.128 = ±40 samples). - var (lhsContiguous, rhsContiguous) = ShiftEpisodes(lhsPoints, rhsPoints, -40, 40); - lhsRanges.AddRange(lhsContiguous); - rhsRanges.AddRange(rhsContiguous); - - // If no valid ranges were found, re-analyze the episodes considering all possible shifts. - if (lhsRanges.Count == 0) + if (lhsRanges.Count > 0) { - _logger.LogTrace("quick scan unsuccessful, falling back to full scan (±{Limit})", limit); - analysisStatistics.FullScans.Increment(); - - (lhsContiguous, rhsContiguous) = ShiftEpisodes(lhsPoints, rhsPoints, -1 * limit, limit); - lhsRanges.AddRange(lhsContiguous); - rhsRanges.AddRange(rhsContiguous); - } - else - { - _logger.LogTrace("quick scan successful"); - analysisStatistics.QuickScans.Increment(); - } - - if (lhsRanges.Count == 0) - { - _logger.LogTrace( - "Unable to find a shared introduction sequence between {LHS} and {RHS}", - lhsId, - rhsId); - + _logger.LogTrace("Index search successful"); + analysisStatistics.IndexSearches.Increment(); analysisStatistics.FirstPassCPUTime.AddDuration(start); - return (new Intro(lhsId), new Intro(rhsId)); + + return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges); } - // After comparing both episodes at all possible shift positions, store the longest time range as the intro. + // ===== Method 2: Quick scan ===== + // Tests if an intro can be found within the first 5 seconds of the episodes. ±5/0.128 = ±40 samples. + (lhsRanges, rhsRanges) = ShiftEpisodes(lhsPoints, rhsPoints, -40, 40); + + if (lhsRanges.Count > 0) + { + _logger.LogTrace("Quick scan successful"); + analysisStatistics.QuickScans.Increment(); + analysisStatistics.FirstPassCPUTime.AddDuration(start); + + return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges); + } + + // ===== Method 3: Full scan ===== + // Compares all elements of the shortest fingerprint to the other fingerprint. + var limit = Math.Min(lhsPoints.Count, rhsPoints.Count); + (lhsRanges, rhsRanges) = ShiftEpisodes(lhsPoints, rhsPoints, -1 * limit, limit); + + if (lhsRanges.Count > 0) + { + _logger.LogTrace("Full scan successful"); + analysisStatistics.FullScans.Increment(); + analysisStatistics.FirstPassCPUTime.AddDuration(start); + + return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges); + } + + // No method was able to find an introduction, return nothing. + + _logger.LogTrace( + "Unable to find a shared introduction sequence between {LHS} and {RHS}", + lhsId, + rhsId); + + analysisStatistics.FirstPassCPUTime.AddDuration(start); + + return (new Intro(lhsId), new Intro(rhsId)); + } + + /// + /// Locates the longest range of similar audio and returns an Intro class for each range. + /// + /// First episode id. + /// First episode shared timecodes. + /// Second episode id. + /// Second episode shared timecodes. + /// Intros for the first and second episodes. + private (Intro Lhs, Intro Rhs) GetLongestTimeRange( + Guid lhsId, + List lhsRanges, + Guid rhsId, + List rhsRanges) + { + // Store the longest time range as the introduction. lhsRanges.Sort(); rhsRanges.Sort(); @@ -466,10 +500,54 @@ public class FingerprinterTask : IScheduledTask rhsIntro.Start = 0; } - analysisStatistics.FirstPassCPUTime.AddDuration(start); + // Create Intro classes for each time range. return (new Intro(lhsId, lhsIntro), new Intro(rhsId, rhsIntro)); } + /// + /// Search for a shared introduction sequence using inverted indexes. + /// + /// Left episode fingerprint points. + /// Right episode fingerprint points. + /// List of shared TimeRanges between the left and right episodes. + private (List Lhs, List Rhs) SearchInvertedIndex( + ReadOnlyCollection lhsPoints, + ReadOnlyCollection rhsPoints) + { + var lhsRanges = new List(); + var rhsRanges = new List(); + + // Generate inverted indexes for the left and right episodes. + var lhsIndex = Chromaprint.CreateInvertedIndex(lhsPoints); + var rhsIndex = Chromaprint.CreateInvertedIndex(rhsPoints); + var indexShifts = new HashSet(); + + // For all audio points in the left episode, check if the right episode has a point which matches exactly. + // If an exact match is found, calculate the shift that must be used to align the points. + foreach (var kvp in lhsIndex) + { + var point = kvp.Key; + + if (rhsIndex.ContainsKey(point)) + { + // TODO: consider all timecodes before falling back + var lhsFirst = (int)lhsIndex[point][0]; + var rhsFirst = (int)rhsIndex[point][0]; + indexShifts.Add(rhsFirst - lhsFirst); + } + } + + // Use all discovered shifts to compare the episodes. + foreach (var shift in indexShifts) + { + var (lhsIndexContiguous, rhsIndexContiguous) = ShiftEpisodes(lhsPoints, rhsPoints, shift, shift); + lhsRanges.AddRange(lhsIndexContiguous); + rhsRanges.AddRange(rhsIndexContiguous); + } + + return (lhsRanges, rhsRanges); + } + /// /// Shifts a pair of episodes through the range of provided shift amounts and returns discovered contiguous time ranges. ///