412 lines
16 KiB
C#
Raw Normal View History

2022-10-28 02:25:57 -05:00
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
2022-10-28 02:25:57 -05:00
using System.Numerics;
using System.Threading;
2024-04-20 12:58:29 +02:00
using ConfusedPolarBear.Plugin.IntroSkipper.Configuration;
2024-08-31 18:56:48 +02:00
using ConfusedPolarBear.Plugin.IntroSkipper.Data;
2022-10-28 02:25:57 -05:00
using Microsoft.Extensions.Logging;
2024-08-31 18:56:48 +02:00
namespace ConfusedPolarBear.Plugin.IntroSkipper.Analyzers;
2024-04-20 12:58:29 +02:00
2022-10-28 02:25:57 -05:00
/// <summary>
/// Chromaprint audio analyzer.
/// </summary>
public class ChromaprintAnalyzer : IMediaFileAnalyzer
{
/// <summary>
/// Seconds of audio in one fingerprint point.
/// This value is defined by the Chromaprint library and should not be changed.
/// </summary>
private const double SamplesToSeconds = 0.1238;
2022-10-28 02:25:57 -05:00
private readonly int _minimumIntroDuration;
2022-10-28 02:25:57 -05:00
private readonly int _maximumDifferences;
2022-10-28 02:25:57 -05:00
private readonly int _invertedIndexShift;
2022-10-28 02:25:57 -05:00
private readonly double _maximumTimeSkip;
2022-10-28 02:25:57 -05:00
private readonly ILogger<ChromaprintAnalyzer> _logger;
2022-10-28 02:25:57 -05:00
2022-10-31 01:00:39 -05:00
private AnalysisMode _analysisMode;
2022-10-28 02:25:57 -05:00
/// <summary>
/// Initializes a new instance of the <see cref="ChromaprintAnalyzer"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ChromaprintAnalyzer(ILogger<ChromaprintAnalyzer> logger)
{
2024-04-20 12:58:29 +02:00
var config = Plugin.Instance?.Configuration ?? new PluginConfiguration();
_maximumDifferences = config.MaximumFingerprintPointDifferences;
_invertedIndexShift = config.InvertedIndexShift;
_maximumTimeSkip = config.MaximumTimeSkip;
_minimumIntroDuration = config.MinimumIntroDuration;
2022-10-28 02:25:57 -05:00
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyCollection<QueuedEpisode> AnalyzeMediaFiles(
IReadOnlyCollection<QueuedEpisode> analysisQueue,
2022-10-28 02:25:57 -05:00
AnalysisMode mode,
CancellationToken cancellationToken)
{
// All intros for this season.
var seasonIntros = new Dictionary<Guid, Segment>();
2022-10-28 02:25:57 -05:00
// Cache of all fingerprints for this season.
var fingerprintCache = new Dictionary<Guid, uint[]>();
// Episode analysis queue based on not analyzed episodes
2022-10-28 02:25:57 -05:00
var episodeAnalysisQueue = new List<QueuedEpisode>(analysisQueue);
// Episodes that were analyzed and do not have an introduction.
var episodesWithoutIntros = episodeAnalysisQueue.Where(e => !e.State.IsAnalyzed(mode)).ToList();
2022-10-28 02:25:57 -05:00
2024-08-31 18:56:48 +02:00
_analysisMode = mode;
2022-10-31 01:00:39 -05:00
if (episodesWithoutIntros.Count == 0 || episodeAnalysisQueue.Count <= 1)
{
return analysisQueue;
}
var episodesWithFingerprint = new List<QueuedEpisode>(episodesWithoutIntros);
// Load fingerprints from cache if available.
episodesWithFingerprint.AddRange(episodeAnalysisQueue.Where(e => e.State.IsAnalyzed(mode) && File.Exists(FFmpegWrapper.GetFingerprintCachePath(e, mode))));
// Ensure at least two fingerprints are present.
if (episodesWithFingerprint.Count == 1)
{
var indexInAnalysisQueue = episodeAnalysisQueue.FindIndex(episode => episode == episodesWithoutIntros[0]);
episodesWithFingerprint.AddRange(episodeAnalysisQueue
.Where((episode, index) => Math.Abs(index - indexInAnalysisQueue) <= 1 && index != indexInAnalysisQueue));
}
seasonIntros = episodesWithFingerprint.Where(e => e.State.IsAnalyzed(mode)).ToDictionary(e => e.EpisodeId, e => Plugin.GetIntroByMode(e.EpisodeId, mode));
2022-10-28 02:25:57 -05:00
// Compute fingerprints for all episodes in the season
foreach (var episode in episodesWithFingerprint)
2022-10-28 02:25:57 -05:00
{
try
{
2022-10-31 01:00:39 -05:00
fingerprintCache[episode.EpisodeId] = FFmpegWrapper.Fingerprint(episode, mode);
2022-10-28 02:25:57 -05:00
2024-04-26 15:06:41 +02:00
// Use reversed fingerprints for credits
if (_analysisMode == AnalysisMode.Credits)
{
Array.Reverse(fingerprintCache[episode.EpisodeId]);
}
2022-10-28 02:25:57 -05:00
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue;
}
}
catch (FingerprintException ex)
{
2022-11-06 21:25:23 -06:00
_logger.LogDebug("Caught fingerprint error: {Ex}", ex);
WarningManager.SetFlag(PluginWarning.InvalidChromaprintFingerprint);
2022-10-28 02:25:57 -05:00
// Fallback to an empty fingerprint on any error
fingerprintCache[episode.EpisodeId] = [];
2022-10-28 02:25:57 -05:00
}
}
// While there are still episodes in the queue
while (episodesWithoutIntros.Count > 0)
2022-10-28 02:25:57 -05:00
{
// Pop the first episode from the queue
var currentEpisode = episodesWithoutIntros[0];
episodesWithoutIntros.RemoveAt(0);
episodesWithFingerprint.Remove(currentEpisode);
2022-10-28 02:25:57 -05:00
// Search through all remaining episodes.
foreach (var remainingEpisode in episodesWithFingerprint)
2022-10-28 02:25:57 -05:00
{
// Compare the current episode to all remaining episodes in the queue.
var (currentIntro, remainingIntro) = CompareEpisodes(
currentEpisode.EpisodeId,
fingerprintCache[currentEpisode.EpisodeId],
remainingEpisode.EpisodeId,
fingerprintCache[remainingEpisode.EpisodeId]);
// Ignore this comparison result if:
// - one of the intros isn't valid, or
// - the introduction exceeds the configured limit
if (
!remainingIntro.Valid ||
2024-08-31 18:56:48 +02:00
(_analysisMode == AnalysisMode.Introduction && remainingIntro.Duration > Plugin.Instance!.Configuration.MaximumIntroDuration))
2022-10-28 02:25:57 -05:00
{
continue;
}
2022-10-31 01:00:39 -05:00
/* Since the Fingerprint() function returns an array of Chromaprint points without time
* information, the times reported from the index search function start from 0.
*
* While this is desired behavior for detecting introductions, it breaks credit
* detection, as the audio we're analyzing was extracted from some point into the file.
*
2024-04-26 15:06:41 +02:00
* To fix this, the starting and ending times need to be switched, as they were previously reversed
* and subtracted from the episode duration to get the reported time range.
2022-10-31 01:00:39 -05:00
*/
2024-08-31 18:56:48 +02:00
if (_analysisMode == AnalysisMode.Credits)
2022-10-31 01:00:39 -05:00
{
2024-04-26 15:06:41 +02:00
// Calculate new values for the current intro
double currentOriginalIntroStart = currentIntro.Start;
currentIntro.Start = currentEpisode.Duration - currentIntro.End;
currentIntro.End = currentEpisode.Duration - currentOriginalIntroStart;
2024-04-26 15:06:41 +02:00
// Calculate new values for the remaining intro
double remainingIntroOriginalStart = remainingIntro.Start;
remainingIntro.Start = remainingEpisode.Duration - remainingIntro.End;
remainingIntro.End = remainingEpisode.Duration - remainingIntroOriginalStart;
2022-10-31 01:00:39 -05:00
}
2022-10-28 02:25:57 -05:00
// Only save the discovered intro if it is:
// - the first intro discovered for this episode
// - longer than the previously discovered intro
if (
!seasonIntros.TryGetValue(currentIntro.EpisodeId, out var savedCurrentIntro) ||
currentIntro.Duration > savedCurrentIntro.Duration)
{
seasonIntros[currentIntro.EpisodeId] = currentIntro;
}
if (
!seasonIntros.TryGetValue(remainingIntro.EpisodeId, out var savedRemainingIntro) ||
remainingIntro.Duration > savedRemainingIntro.Duration)
{
seasonIntros[remainingIntro.EpisodeId] = remainingIntro;
}
break;
}
// If no intro is found at this point, the popped episode is not reinserted into the queue.
if (seasonIntros.ContainsKey(currentEpisode.EpisodeId))
{
episodesWithFingerprint.Add(currentEpisode);
episodeAnalysisQueue.FirstOrDefault(x => x.EpisodeId == currentEpisode.EpisodeId)?.State.SetAnalyzed(mode, true);
}
2022-10-28 02:25:57 -05:00
}
// If cancellation was requested, report that no episodes were analyzed.
if (cancellationToken.IsCancellationRequested)
{
return analysisQueue;
}
// Adjust all introduction times.
var analyzerHelper = new AnalyzerHelper(_logger);
seasonIntros = analyzerHelper.AdjustIntroTimes(analysisQueue, seasonIntros, _analysisMode);
2022-10-28 02:25:57 -05:00
2024-08-31 18:56:48 +02:00
Plugin.Instance!.UpdateTimestamps(seasonIntros, _analysisMode);
2022-10-28 02:25:57 -05:00
return episodeAnalysisQueue;
2022-10-28 02:25:57 -05:00
}
/// <summary>
/// Analyze two episodes to find an introduction sequence shared between them.
/// </summary>
/// <param name="lhsId">First episode id.</param>
/// <param name="lhsPoints">First episode fingerprint points.</param>
/// <param name="rhsId">Second episode id.</param>
/// <param name="rhsPoints">Second episode fingerprint points.</param>
/// <returns>Intros for the first and second episodes.</returns>
public (Segment Lhs, Segment Rhs) CompareEpisodes(
2022-10-28 02:25:57 -05:00
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints)
{
// 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(lhsId, lhsPoints, rhsId, rhsPoints);
if (lhsRanges.Count > 0)
{
_logger.LogTrace("Index search successful");
return GetLongestTimeRange(lhsId, lhsRanges, rhsId, rhsRanges);
}
_logger.LogTrace(
"Unable to find a shared introduction sequence between {LHS} and {RHS}",
lhsId,
rhsId);
return (new Segment(lhsId), new Segment(rhsId));
2022-10-28 02:25:57 -05:00
}
/// <summary>
/// Locates the longest range of similar audio and returns an Intro class for each range.
/// </summary>
/// <param name="lhsId">First episode id.</param>
/// <param name="lhsRanges">First episode shared timecodes.</param>
/// <param name="rhsId">Second episode id.</param>
/// <param name="rhsRanges">Second episode shared timecodes.</param>
/// <returns>Intros for the first and second episodes.</returns>
private (Segment Lhs, Segment Rhs) GetLongestTimeRange(
2022-10-28 02:25:57 -05:00
Guid lhsId,
List<TimeRange> lhsRanges,
Guid rhsId,
List<TimeRange> rhsRanges)
{
// Store the longest time range as the introduction.
lhsRanges.Sort();
rhsRanges.Sort();
var lhsIntro = lhsRanges[0];
var rhsIntro = rhsRanges[0];
// If the intro starts early in the episode, move it to the beginning.
if (lhsIntro.Start <= 5)
{
lhsIntro.Start = 0;
}
if (rhsIntro.Start <= 5)
{
rhsIntro.Start = 0;
}
// Create Intro classes for each time range.
return (new Segment(lhsId, lhsIntro), new Segment(rhsId, rhsIntro));
2022-10-28 02:25:57 -05:00
}
/// <summary>
/// Search for a shared introduction sequence using inverted indexes.
/// </summary>
/// <param name="lhsId">LHS ID.</param>
/// <param name="lhsPoints">Left episode fingerprint points.</param>
/// <param name="rhsId">RHS ID.</param>
/// <param name="rhsPoints">Right episode fingerprint points.</param>
/// <returns>List of shared TimeRanges between the left and right episodes.</returns>
private (List<TimeRange> Lhs, List<TimeRange> Rhs) SearchInvertedIndex(
Guid lhsId,
uint[] lhsPoints,
Guid rhsId,
uint[] rhsPoints)
{
var lhsRanges = new List<TimeRange>();
var rhsRanges = new List<TimeRange>();
// Generate inverted indexes for the left and right episodes.
2024-08-31 18:56:48 +02:00
var lhsIndex = FFmpegWrapper.CreateInvertedIndex(lhsId, lhsPoints, _analysisMode);
var rhsIndex = FFmpegWrapper.CreateInvertedIndex(rhsId, rhsPoints, _analysisMode);
2022-10-28 02:25:57 -05:00
var indexShifts = new HashSet<int>();
// 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 originalPoint = kvp.Key;
for (var i = -1 * _invertedIndexShift; i <= _invertedIndexShift; i++)
2022-10-28 02:25:57 -05:00
{
var modifiedPoint = (uint)(originalPoint + i);
if (rhsIndex.TryGetValue(modifiedPoint, out var rhsModifiedPoint))
2022-10-28 02:25:57 -05:00
{
2024-04-20 12:58:29 +02:00
var lhsFirst = lhsIndex[originalPoint];
var rhsFirst = rhsModifiedPoint;
2022-10-28 02:25:57 -05:00
indexShifts.Add(rhsFirst - lhsFirst);
}
}
}
// Use all discovered shifts to compare the episodes.
foreach (var shift in indexShifts)
{
var (lhsIndexContiguous, rhsIndexContiguous) = FindContiguous(lhsPoints, rhsPoints, shift);
if (lhsIndexContiguous.End > 0 && rhsIndexContiguous.End > 0)
{
lhsRanges.Add(lhsIndexContiguous);
rhsRanges.Add(rhsIndexContiguous);
}
}
return (lhsRanges, rhsRanges);
}
/// <summary>
/// Finds the longest contiguous region of similar audio between two fingerprints using the provided shift amount.
/// </summary>
/// <param name="lhs">First fingerprint to compare.</param>
/// <param name="rhs">Second fingerprint to compare.</param>
/// <param name="shiftAmount">Amount to shift one fingerprint by.</param>
private (TimeRange Lhs, TimeRange Rhs) FindContiguous(
uint[] lhs,
uint[] rhs,
int shiftAmount)
{
var leftOffset = 0;
var rightOffset = 0;
// Calculate the offsets for the left and right hand sides.
if (shiftAmount < 0)
{
leftOffset -= shiftAmount;
}
else
{
rightOffset += shiftAmount;
}
// Store similar times for both LHS and RHS.
var lhsTimes = new List<double>();
var rhsTimes = new List<double>();
var upperLimit = Math.Min(lhs.Length, rhs.Length) - Math.Abs(shiftAmount);
// XOR all elements in LHS and RHS, using the shift amount from above.
for (var i = 0; i < upperLimit; i++)
{
// XOR both samples at the current position.
var lhsPosition = i + leftOffset;
var rhsPosition = i + rightOffset;
var diff = lhs[lhsPosition] ^ rhs[rhsPosition];
// If the difference between the samples is small, flag both times as similar.
if (CountBits(diff) > _maximumDifferences)
2022-10-28 02:25:57 -05:00
{
continue;
}
var lhsTime = lhsPosition * SamplesToSeconds;
var rhsTime = rhsPosition * SamplesToSeconds;
lhsTimes.Add(lhsTime);
rhsTimes.Add(rhsTime);
}
// Ensure the last timestamp is checked
lhsTimes.Add(double.MaxValue);
rhsTimes.Add(double.MaxValue);
// Now that both fingerprints have been compared at this shift, see if there's a contiguous time range.
var lContiguous = TimeRangeHelpers.FindContiguous(lhsTimes.ToArray(), _maximumTimeSkip);
if (lContiguous is null || lContiguous.Duration < _minimumIntroDuration)
2022-10-28 02:25:57 -05:00
{
return (new TimeRange(), new TimeRange());
}
// Since LHS had a contiguous time range, RHS must have one also.
var rContiguous = TimeRangeHelpers.FindContiguous(rhsTimes.ToArray(), _maximumTimeSkip)!;
2022-10-28 02:25:57 -05:00
return (lContiguous, rContiguous);
}
/// <summary>
/// Count the number of bits that are set in the provided number.
/// </summary>
/// <param name="number">Number to count bits in.</param>
/// <returns>Number of bits that are equal to 1.</returns>
public int CountBits(uint number)
{
return BitOperations.PopCount(number);
}
}