Der Kern des Vorspann-Abgleichs — ohne Anschluss, aber geprüft
Ein Vorspann ist das, was in jeder Folge einer Staffel gleich klingt. Sonst ist an einer Folge nichts, woran er sich erkennen liesse: er sieht aus wie Handlung und klingt wie Handlung, er wiederholt sich nur. `Tonabgleich` sucht das längste Stück, das zwei Abdruckreihen teilen — reine Rechnung, ohne Datei und ohne ffmpeg, deshalb mit sieben Prüfungen belegt. Darunter die wichtigere Richtung: zwei Folgen ohne Gemeinsamkeit ergeben nichts. Ein nicht gefundener Vorspann kostet einen Knopf, ein erfundener schneidet dem Zuschauer eine halbe Minute Handlung weg. Die Abdrücke holt `fpcalc` aus Chromaprint, nicht selbstgeschriebene Signalverarbeitung. Dasselbe tut Jellyfins Intro Skipper, aus demselben Grund. Das Programk liegt ab jetzt im Abbild; fehlt es, gibt es eben keine Marken — sie sind eine Zugabe, kein Teil der Wiedergabe. Noch ruft das niemand auf. Tabelle, Lauf und Knöpfe kommen als Nächstes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bfe263d495
commit
bcbe5557d7
@@ -0,0 +1,203 @@
|
||||
namespace Flaro.Core.Media;
|
||||
|
||||
/// <summary>Ein Abschnitt, den zwei Tonspuren gemeinsam haben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Die Stellen sind in Sekunden und beziehen sich jeweils auf die eigene
|
||||
/// Folge: dasselbe Intro liegt in Folge 1 oft bei 0:30 und in Folge 2 bei
|
||||
/// 1:10, weil davor unterschiedlich viel Handlung steht.
|
||||
/// </remarks>
|
||||
public sealed record Gemeinsamkeit(double AVon, double ABis, double BVon, double BBis)
|
||||
{
|
||||
public double Laenge => ABis - AVon;
|
||||
}
|
||||
|
||||
/// <summary>Findet heraus, welches Stück zwei Folgen gemeinsam haben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Das ist die ganze Idee hinter dem Überspringen.</b> Ein Vorspann ist
|
||||
/// das, was in jeder Folge einer Staffel gleich klingt — dieselbe Musik,
|
||||
/// dieselbe Länge. Sonst ist an einer Folge nichts, woran sich ein Vorspann
|
||||
/// erkennen liesse: er sieht aus wie Handlung und klingt wie Handlung, er
|
||||
/// wiederholt sich nur.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verglichen werden Tonabdrücke, keine Töne.</b> Was hereinkommt, ist je
|
||||
/// Folge eine Reihe von 32-Bit-Zahlen, eine je Zeitscheibe — der Abdruck,
|
||||
/// den Chromaprint aus dem Ton rechnet. Zwei Aufnahmen desselben Stücks
|
||||
/// ergeben darin fast dieselben Zahlen, auch bei anderer Lautstärke, anderem
|
||||
/// Codec und anderer Bitrate. Deshalb wird nicht auf Gleichheit geprüft,
|
||||
/// sondern gezählt, wie viele Bits sich unterscheiden.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reine Rechnung, deshalb hier.</b> Das Holen der Abdrücke braucht
|
||||
/// ffmpeg und eine Datei; das Vergleichen braucht nichts. Getrennt lässt
|
||||
/// sich das Schwierige prüfen, ohne eine Mediendatei vorzuhalten.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Tonabgleich
|
||||
{
|
||||
/// <summary>Wie viele Bits zweier Abdrücke sich unterscheiden dürfen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Von zweiunddreissig. Bei null fände man nichts — zwei Fassungen
|
||||
/// desselben Vorspanns sind nie bitgleich. Bei zu viel findet man
|
||||
/// überall etwas: Stille gleicht Stille.
|
||||
/// </remarks>
|
||||
public const int StandardBitabstand = 6;
|
||||
|
||||
/// <summary>Wie viele unpassende Zeitscheiben am Stück eine Lücke sein darf.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein Vorspann ist selten durchgehend gleich: über der Musik liegen
|
||||
/// Namen, und manche Folge blendet einen Satz Handlung hinein. Ohne
|
||||
/// Nachsicht zerfiele ein Vorspann in fünf kurze Treffer, von denen
|
||||
/// keiner lang genug wäre.
|
||||
/// </remarks>
|
||||
public const int StandardLücke = 10;
|
||||
|
||||
/// <summary>Wie kurz ein gemeinsames Stück sein darf, um noch zu zählen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Unter zehn Sekunden lohnt kein Knopf — und je kürzer die Schwelle,
|
||||
/// desto eher findet der Abgleich zwei zufällig ähnliche Takte.
|
||||
/// </remarks>
|
||||
public const double StandardMindestlänge = 10;
|
||||
|
||||
/// <summary>Sucht das längste Stück, das <paramref name="a"/> und <paramref name="b"/> teilen.</summary>
|
||||
///
|
||||
/// <param name="sekundenJeAbdruck">
|
||||
/// Wie viel Zeit eine Zeitscheibe abdeckt. Chromaprint rechnet mit
|
||||
/// 11025 Hz und 4096 Abtastwerten bei zwei Dritteln Überlappung — das
|
||||
/// sind rund 0,1238 Sekunden.
|
||||
/// </param>
|
||||
public static Gemeinsamkeit? Finden(
|
||||
IReadOnlyList<uint> a,
|
||||
IReadOnlyList<uint> b,
|
||||
double sekundenJeAbdruck,
|
||||
double mindestlänge = StandardMindestlänge,
|
||||
int bitabstand = StandardBitabstand,
|
||||
int lücke = StandardLücke)
|
||||
{
|
||||
if (a.Count == 0 || b.Count == 0 || sekundenJeAbdruck <= 0) return null;
|
||||
|
||||
var mindestens = (int)Math.Ceiling(mindestlänge / sekundenJeAbdruck);
|
||||
if (mindestens <= 0) mindestens = 1;
|
||||
|
||||
Gemeinsamkeit? beste = null;
|
||||
|
||||
// **Jede Verschiebung durchprobieren.** Das Intro steht in beiden
|
||||
// Folgen an einer anderen Stelle; gesucht ist die Verschiebung, bei
|
||||
// der die meisten Zeitscheiben aufeinanderpassen. Bei zwei Minuten
|
||||
// Abdruck sind das ein paar Millionen Vergleiche — ein Wimpernschlag
|
||||
// gegen das Lesen der Datei, aus der sie stammen.
|
||||
for (var versatz = -(b.Count - 1); versatz < a.Count; versatz++)
|
||||
{
|
||||
var vonA = Math.Max(0, versatz);
|
||||
var vonB = Math.Max(0, -versatz);
|
||||
var länge = Math.Min(a.Count - vonA, b.Count - vonB);
|
||||
if (länge < mindestens) continue;
|
||||
|
||||
var lauf = LängsterLauf(a, b, vonA, vonB, länge, bitabstand, lücke);
|
||||
if (lauf is not var (start, dauer) || dauer < mindestens) continue;
|
||||
|
||||
if (beste is not null && dauer * sekundenJeAbdruck <= beste.Laenge) continue;
|
||||
|
||||
beste = new Gemeinsamkeit(
|
||||
(vonA + start) * sekundenJeAbdruck,
|
||||
(vonA + start + dauer) * sekundenJeAbdruck,
|
||||
(vonB + start) * sekundenJeAbdruck,
|
||||
(vonB + start + dauer) * sekundenJeAbdruck);
|
||||
}
|
||||
|
||||
return beste is not null && beste.Laenge >= mindestlänge ? beste : null;
|
||||
}
|
||||
|
||||
/// <summary>Der längste zusammenhängende Treffer bei einer Verschiebung.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Zusammenhängend mit Nachsicht: bis zu <paramref name="lücke"/>
|
||||
/// unpassende Zeitscheiben am Stück gelten noch als derselbe Lauf. Am
|
||||
/// Ende wird die angehängte Lücke wieder abgezogen — sonst wüchse jeder
|
||||
/// Fund um zehn Scheiben Stille.
|
||||
/// </remarks>
|
||||
private static (int Start, int Dauer)? LängsterLauf(
|
||||
IReadOnlyList<uint> a, IReadOnlyList<uint> b,
|
||||
int vonA, int vonB, int länge, int bitabstand, int lücke)
|
||||
{
|
||||
var besterStart = 0;
|
||||
var besteDauer = 0;
|
||||
|
||||
var start = -1;
|
||||
var letzterTreffer = -1;
|
||||
|
||||
for (var i = 0; i < länge; i++)
|
||||
{
|
||||
var passt = System.Numerics.BitOperations.PopCount(a[vonA + i] ^ b[vonB + i]) <= bitabstand;
|
||||
|
||||
if (passt)
|
||||
{
|
||||
if (start < 0) start = i;
|
||||
letzterTreffer = i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start < 0) continue;
|
||||
if (i - letzterTreffer <= lücke) continue;
|
||||
|
||||
var dauer = letzterTreffer - start + 1;
|
||||
if (dauer > besteDauer) { besteDauer = dauer; besterStart = start; }
|
||||
start = -1;
|
||||
}
|
||||
|
||||
if (start >= 0)
|
||||
{
|
||||
var dauer = letzterTreffer - start + 1;
|
||||
if (dauer > besteDauer) { besteDauer = dauer; besterStart = start; }
|
||||
}
|
||||
|
||||
return besteDauer > 0 ? (besterStart, besteDauer) : null;
|
||||
}
|
||||
|
||||
/// <summary>Was mehrere Folgen einer Staffel gemeinsam haben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Jede gegen jede wäre zu viel.</b> Bei zwanzig Folgen sind das
|
||||
/// hundertneunzig Vergleiche. Es genügt, jede Folge gegen die nächste zu
|
||||
/// halten: was alle teilen, teilen auch zwei benachbarte, und zwei
|
||||
/// Nachbarn stammen sicher aus derselben Staffel.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die letzte Folge bekommt ihren Fund vom Vorgänger.</b> Sie hat
|
||||
/// keinen Nachfolger mehr, und ohne diesen Griff fehlte in jeder Staffel
|
||||
/// genau einer Folge der Vorspann.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static IReadOnlyDictionary<TSchlüssel, (double Von, double Bis)> ReiheAbgleichen<TSchlüssel>(
|
||||
IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList<uint> Abdruck)> folgen,
|
||||
double sekundenJeAbdruck,
|
||||
double mindestlänge = StandardMindestlänge)
|
||||
where TSchlüssel : notnull
|
||||
{
|
||||
var gefunden = new Dictionary<TSchlüssel, (double Von, double Bis)>();
|
||||
if (folgen.Count < 2) return gefunden;
|
||||
|
||||
for (var i = 0; i + 1 < folgen.Count; i++)
|
||||
{
|
||||
var (schlüsselA, abdruckA) = folgen[i];
|
||||
var (schlüsselB, abdruckB) = folgen[i + 1];
|
||||
|
||||
var treffer = Finden(abdruckA, abdruckB, sekundenJeAbdruck, mindestlänge);
|
||||
if (treffer is null) continue;
|
||||
|
||||
// Der erste Fund je Folge gewinnt: er stammt vom Vergleich mit
|
||||
// dem Vorgänger, und die Paare laufen von vorn nach hinten.
|
||||
gefunden.TryAdd(schlüsselA, (treffer.AVon, treffer.ABis));
|
||||
gefunden.TryAdd(schlüsselB, (treffer.BVon, treffer.BBis));
|
||||
}
|
||||
|
||||
return gefunden;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user