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
@@ -49,6 +49,7 @@ ARG TARGETARCH
|
|||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
|
||||||
|
libchromaprint-tools \
|
||||||
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
&& if [ "$TARGETARCH" = "amd64" ]; then \
|
||||||
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
|
||||||
fi \
|
fi \
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using Flaro.Core.Media;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>Was zwei Folgen gemeinsam haben.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Mit erfundenen Abdrücken statt mit Mediendateien.</b> Was hier geprüft
|
||||||
|
/// wird, ist die Suche nach dem gemeinsamen Stück — nicht, ob Chromaprint
|
||||||
|
/// rechnen kann. Eine Prüfung, die eine Serie mit echten Folgen braucht,
|
||||||
|
/// liefe nirgends ausser auf dem Rechner dessen, der sie geschrieben hat.
|
||||||
|
/// </remarks>
|
||||||
|
public class TonabgleichTests
|
||||||
|
{
|
||||||
|
/// <summary>Chromaprint mit Standardeinstellungen.</summary>
|
||||||
|
private const double JeAbdruck = 0.1238;
|
||||||
|
|
||||||
|
/// <summary>Eine Folge von Abdrücken, die sich nicht wiederholt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Aus einem festen Startwert gerechnet, nicht gewürfelt: dieselbe
|
||||||
|
/// Prüfung soll morgen dasselbe Ergebnis haben.
|
||||||
|
/// </remarks>
|
||||||
|
private static uint[] Rauschen(int anzahl, int saat)
|
||||||
|
{
|
||||||
|
var werte = new uint[anzahl];
|
||||||
|
var stand = (uint)saat * 2654435761u + 1u;
|
||||||
|
for (var i = 0; i < anzahl; i++)
|
||||||
|
{
|
||||||
|
stand ^= stand << 13;
|
||||||
|
stand ^= stand >> 17;
|
||||||
|
stand ^= stand << 5;
|
||||||
|
werte[i] = stand;
|
||||||
|
}
|
||||||
|
return werte;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hängt Abschnitte aneinander.</summary>
|
||||||
|
private static uint[] Aneinander(params uint[][] teile) =>
|
||||||
|
teile.SelectMany(t => t).ToArray();
|
||||||
|
|
||||||
|
/// <summary>Wie viele Zeitscheiben so viele Sekunden sind.</summary>
|
||||||
|
private static int Scheiben(double sekunden) => (int)Math.Round(sekunden / JeAbdruck);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ein_gemeinsamer_Vorspann_wird_gefunden()
|
||||||
|
{
|
||||||
|
var vorspann = Rauschen(Scheiben(30), saat: 1);
|
||||||
|
|
||||||
|
// Folge 1: zehn Sekunden Handlung, dann der Vorspann, dann weiter.
|
||||||
|
var a = Aneinander(Rauschen(Scheiben(10), 2), vorspann, Rauschen(Scheiben(60), 3));
|
||||||
|
// Folge 2: der Vorspann liegt viel später.
|
||||||
|
var b = Aneinander(Rauschen(Scheiben(70), 4), vorspann, Rauschen(Scheiben(20), 5));
|
||||||
|
|
||||||
|
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
|
||||||
|
|
||||||
|
Assert.That(treffer, Is.Not.Null);
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(treffer!.AVon, Is.EqualTo(10).Within(1.0));
|
||||||
|
Assert.That(treffer.ABis, Is.EqualTo(40).Within(1.0));
|
||||||
|
Assert.That(treffer.BVon, Is.EqualTo(70).Within(1.0));
|
||||||
|
Assert.That(treffer.BBis, Is.EqualTo(100).Within(1.0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Zwei Folgen ohne Gemeinsamkeit ergeben keinen Fund.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Der wichtigere der beiden Fälle.</b> Ein Vorspann, der nicht
|
||||||
|
/// gefunden wird, kostet einen Knopf. Ein Vorspann, der erfunden wird,
|
||||||
|
/// schneidet dem Zuschauer eine halbe Minute Handlung weg.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Gemeinsamkeit_kommt_nichts()
|
||||||
|
{
|
||||||
|
var a = Rauschen(Scheiben(120), 11);
|
||||||
|
var b = Rauschen(Scheiben(120), 12);
|
||||||
|
|
||||||
|
Assert.That(Tonabgleich.Finden(a, b, JeAbdruck), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein zu kurzes gemeinsames Stück zählt nicht.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Drei_Sekunden_sind_kein_Vorspann()
|
||||||
|
{
|
||||||
|
var kurz = Rauschen(Scheiben(3), 21);
|
||||||
|
var a = Aneinander(Rauschen(Scheiben(30), 22), kurz, Rauschen(Scheiben(30), 23));
|
||||||
|
var b = Aneinander(Rauschen(Scheiben(50), 24), kurz, Rauschen(Scheiben(10), 25));
|
||||||
|
|
||||||
|
Assert.That(Tonabgleich.Finden(a, b, JeAbdruck), Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ein paar abweichende Bits machen den Fund nicht kaputt.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Derselbe Vorspann aus zwei Dateien ist nie bitgleich: andere Bitrate,
|
||||||
|
/// anderer Codec, andere Lautstärke. Wäre Gleichheit verlangt, fände der
|
||||||
|
/// Abgleich an echten Folgen nie etwas.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Kleine_Abweichungen_stoeren_nicht()
|
||||||
|
{
|
||||||
|
var vorspann = Rauschen(Scheiben(30), 31);
|
||||||
|
var leichtAnders = vorspann.Select((w, i) => i % 3 == 0 ? w ^ 0b101u : w).ToArray();
|
||||||
|
|
||||||
|
var a = Aneinander(Rauschen(Scheiben(5), 32), vorspann, Rauschen(Scheiben(40), 33));
|
||||||
|
var b = Aneinander(Rauschen(Scheiben(20), 34), leichtAnders, Rauschen(Scheiben(40), 35));
|
||||||
|
|
||||||
|
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
|
||||||
|
|
||||||
|
Assert.That(treffer, Is.Not.Null);
|
||||||
|
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine eingeblendete Ansage mitten im Vorspann reisst ihn nicht entzwei.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Eine_kurze_Luecke_haelt_den_Lauf_zusammen()
|
||||||
|
{
|
||||||
|
var vorspann = Rauschen(Scheiben(30), 41);
|
||||||
|
// Eine Sekunde mittendrin ist in der einen Folge anders.
|
||||||
|
var mitAnsage = (uint[])vorspann.Clone();
|
||||||
|
var mitte = mitAnsage.Length / 2;
|
||||||
|
for (var i = mitte; i < mitte + Scheiben(1); i++) mitAnsage[i] = 0xDEADBEEF;
|
||||||
|
|
||||||
|
var a = Aneinander(Rauschen(Scheiben(5), 42), vorspann, Rauschen(Scheiben(30), 43));
|
||||||
|
var b = Aneinander(Rauschen(Scheiben(15), 44), mitAnsage, Rauschen(Scheiben(30), 45));
|
||||||
|
|
||||||
|
var treffer = Tonabgleich.Finden(a, b, JeAbdruck);
|
||||||
|
|
||||||
|
Assert.That(treffer, Is.Not.Null);
|
||||||
|
Assert.That(treffer!.Laenge, Is.EqualTo(30).Within(2.0),
|
||||||
|
"Die Lücke gehoert zum Vorspann, nicht dahinter");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine ganze Staffel auf einmal.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Jede_Folge_der_Staffel_bekommt_ihre_Stelle()
|
||||||
|
{
|
||||||
|
var vorspann = Rauschen(Scheiben(25), 51);
|
||||||
|
var folgen = new List<(int, IReadOnlyList<uint>)>();
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
folgen.Add((i, Aneinander(
|
||||||
|
Rauschen(Scheiben(5 + i * 10), 60 + i), vorspann, Rauschen(Scheiben(30), 70 + i))));
|
||||||
|
}
|
||||||
|
|
||||||
|
var gefunden = Tonabgleich.ReiheAbgleichen(folgen, JeAbdruck);
|
||||||
|
|
||||||
|
Assert.That(gefunden, Has.Count.EqualTo(5), "auch die letzte Folge");
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
Assert.That(gefunden[i].Von, Is.EqualTo(5 + i * 10).Within(1.5), $"Folge {i}");
|
||||||
|
Assert.That(gefunden[i].Bis - gefunden[i].Von, Is.EqualTo(25).Within(2.0), $"Folge {i}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine einzelne Folge hat niemanden zum Vergleichen.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Eine_Folge_allein_ergibt_nichts()
|
||||||
|
{
|
||||||
|
var eine = new List<(int, IReadOnlyList<uint>)> { (0, Rauschen(Scheiben(60), 81)) };
|
||||||
|
Assert.That(Tonabgleich.ReiheAbgleichen(eine, JeAbdruck), Is.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using Flaro.Media;
|
||||||
|
|
||||||
|
namespace Flaro.Media.Tests;
|
||||||
|
|
||||||
|
/// <summary>Die Ausgabe von <c>fpcalc -raw</c> lesen.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ohne das Programm selbst.</b> Ob Chromaprint rechnen kann, ist nicht
|
||||||
|
/// unsere Frage; ob wir seine Ausgabe richtig verstehen, schon. Die Zahlen
|
||||||
|
/// kommen mit Vorzeichen, gemeint sind die Bitmuster — wer das verwechselt,
|
||||||
|
/// bekommt einen Abdruck, der nie zu einem zweiten passt.
|
||||||
|
/// </remarks>
|
||||||
|
public class TonabdruckleserTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Die_Zahlen_werden_als_Bitmuster_gelesen()
|
||||||
|
{
|
||||||
|
var abdruck = Tonabdruckleser.Lesen("DURATION=120\nFINGERPRINT=1,-1,2147483647,-2147483648\n");
|
||||||
|
|
||||||
|
Assert.That(abdruck, Is.EqualTo(new uint[] { 1u, 0xFFFFFFFFu, 0x7FFFFFFFu, 0x80000000u }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Abdruckzeile_kommt_nichts()
|
||||||
|
{
|
||||||
|
Assert.That(Tonabdruckleser.Lesen("DURATION=120\n"), Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Eine_leere_Zeile_ergibt_keinen_Abdruck()
|
||||||
|
{
|
||||||
|
Assert.That(Tonabdruckleser.Lesen("FINGERPRINT=\n"), Is.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Eine Zeitscheibe ist rund 124 Millisekunden.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Daran hängt jede Zeitangabe, die aus einem Abdruck entsteht. Stimmt
|
||||||
|
/// die Zahl nicht, liegt jede Marke um denselben Faktor daneben — und
|
||||||
|
/// das sähe aus wie ein Fehler in der Erkennung.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Eine_Zeitscheibe_dauert_124_Millisekunden()
|
||||||
|
{
|
||||||
|
Assert.That(Tonabdruckleser.SekundenJeAbdruck, Is.EqualTo(0.1238).Within(0.0005));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user