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:
Scarriffle
2026-10-12 20:56:37 +02:00
co-authored by Claude Opus 5
parent bfe263d495
commit bcbe5557d7
5 changed files with 589 additions and 0 deletions
+203
View File
@@ -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;
}
}
+173
View File
@@ -0,0 +1,173 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Holt den Tonabdruck eines Dateiabschnitts.</summary>
///
/// <remarks>
/// <para>
/// <b>Mit <c>fpcalc</c> aus Chromaprint, nicht von Hand.</b> Was hier
/// gebraucht wird, ist ein Abdruck, der denselben Ton in zwei Dateien
/// wiedererkennt, auch bei anderem Codec und anderer Lautstärke. Das ist
/// Signalverarbeitung mit einer Fourier-Transformation darin; sie noch
/// einmal zu schreiben hiesse, einen seit zwanzig Jahren erprobten Algorith-
/// mus nachzubauen und dabei schlechter zu werden. Dieselbe Entscheidung
/// trifft Jellyfins Intro Skipper, und aus demselben Grund.
/// </para>
/// <para>
/// <b>Fehlt das Programm, gibt es eben keine Marken.</b> Es liegt im Abbild
/// (<c>libchromaprint-tools</c>), aber nicht auf jedem Rechner, auf dem
/// jemand den Server baut. Darüber zu stolpern wäre falsch: Marken sind eine
/// Zugabe, kein Teil der Wiedergabe.
/// </para>
/// <para>
/// <b>Nur ein Abschnitt, nicht die ganze Folge.</b> Ein Vorspann liegt in
/// den ersten Minuten, ein Abspann in den letzten. Eine ganze Folge
/// abzutasten kostet das Zehnfache an Zeit und bringt nichts ausser
/// Gelegenheiten für falsche Funde.
/// </para>
/// </remarks>
public sealed class Tonabdruckleser(ILogger<Tonabdruckleser> log)
{
/// <summary>Wie viel Zeit eine Zahl des Abdrucks abdeckt.</summary>
///
/// <remarks>
/// Chromaprint rechnet mit 11025 Hz, 4096 Abtastwerten je Scheibe und
/// zwei Dritteln Überlappung: 4096/3 / 11025 Sekunden. Die Zahl steht
/// hier, weil nur mit ihr aus einem Abdruckbereich eine Zeitangabe wird.
/// </remarks>
public const double SekundenJeAbdruck = 4096.0 / 3.0 / 11025.0;
/// <summary>Wo <c>fpcalc</c> liegt — überschreibbar wie ffmpeg selbst.</summary>
public static string Programm =>
Environment.GetEnvironmentVariable("FLARO_FPCALC") is { Length: > 0 } p ? p : "fpcalc";
/// <summary>Ob das Programm überhaupt da ist.</summary>
///
/// <remarks>
/// Einmal geprüft und gemerkt: die Antwort ändert sich im Betrieb nicht,
/// und bei zwanzig Folgen wären es zwanzig Prozessstarts für dieselbe
/// Auskunft.
/// </remarks>
private static bool? _vorhanden;
public async Task<bool> VorhandenAsync(CancellationToken ct = default)
{
if (_vorhanden is { } bekannt) return bekannt;
try
{
using var prozess = Process.Start(new ProcessStartInfo(Programm)
{
ArgumentList = { "-version" },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
});
if (prozess is null) return (_vorhanden = false).Value;
await prozess.WaitForExitAsync(ct);
return (_vorhanden = prozess.ExitCode == 0).Value;
}
catch (Exception)
{
return (_vorhanden = false).Value;
}
}
/// <summary>Der Abdruck eines Abschnitts, oder eine leere Reihe.</summary>
///
/// <param name="ab">Ab welcher Sekunde gelesen wird.</param>
/// <param name="dauer">Wie viele Sekunden gelesen werden.</param>
public async Task<IReadOnlyList<uint>> LesenAsync(
string pfad, double ab, double dauer, CancellationToken ct = default)
{
if (!await VorhandenAsync(ct))
{
log.LogDebug("Kein fpcalc vorhanden — keine Marken für {Pfad}.", pfad);
return [];
}
var start = new ProcessStartInfo(Programm)
{
ArgumentList =
{
"-raw",
// Beide als ganze Sekunden: fpcalc nimmt nichts anderes an.
"-ts", Math.Max(0, (int)ab).ToString(CultureInfo.InvariantCulture),
"-length", Math.Max(1, (int)Math.Ceiling(dauer)).ToString(CultureInfo.InvariantCulture),
pfad,
},
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Grosszügig: bei einem Film auf einer schlafenden Platte dauert
// allein das Anlaufen Sekunden. Knapp bemessen hiesse, bei jedem
// langsamen Datenträger die Marken zu verlieren.
frist.CancelAfter(TimeSpan.FromMinutes(3));
try
{
using var prozess = Process.Start(start);
if (prozess is null) return [];
var lesen = prozess.StandardOutput.ReadToEndAsync(frist.Token);
var fehlerLesen = prozess.StandardError.ReadToEndAsync(frist.Token);
await prozess.WaitForExitAsync(frist.Token);
var ausgabe = await lesen;
var fehlertext = await fehlerLesen;
if (prozess.ExitCode != 0)
{
log.LogWarning("fpcalc gab {Code} für {Pfad} zurück: {Fehler}",
prozess.ExitCode, pfad, fehlertext.Trim());
return [];
}
return Lesen(ausgabe);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "fpcalc liess sich für {Pfad} nicht ausführen.", pfad);
return [];
}
}
/// <summary>Liest die Ausgabe von <c>fpcalc -raw</c>.</summary>
///
/// <remarks>
/// Sie besteht aus Zeilen der Form <c>SCHLÜSSEL=WERT</c>; gebraucht wird
/// <c>FINGERPRINT</c>, eine mit Kommas getrennte Reihe von Zahlen. Die
/// Zahlen sind vorzeichenbehaftet, gemeint sind aber die Bitmuster —
/// deshalb über <c>int</c> und dann ungedeutet.
/// </remarks>
public static IReadOnlyList<uint> Lesen(string ausgabe)
{
foreach (var zeile in ausgabe.Split('\n'))
{
if (!zeile.StartsWith("FINGERPRINT=", StringComparison.Ordinal)) continue;
var roh = zeile["FINGERPRINT=".Length..].Trim();
if (roh.Length == 0) return [];
var teile = roh.Split(',');
var werte = new List<uint>(teile.Length);
foreach (var teil in teile)
{
if (int.TryParse(teil, NumberStyles.Integer, CultureInfo.InvariantCulture, out var zahl))
werte.Add(unchecked((uint)zahl));
else if (uint.TryParse(teil, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ohne))
werte.Add(ohne);
}
return werte;
}
return [];
}
}