diff --git a/server/Dockerfile b/server/Dockerfile
index 467f5d9..818047f 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -49,6 +49,7 @@ ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg libva2 libva-drm2 mesa-va-drivers curl \
+ libchromaprint-tools \
&& if [ "$TARGETARCH" = "amd64" ]; then \
apt-get install -y --no-install-recommends intel-media-va-driver i965-va-driver; \
fi \
diff --git a/server/src/Flaro.Core/Media/Tonabgleich.cs b/server/src/Flaro.Core/Media/Tonabgleich.cs
new file mode 100644
index 0000000..f879c68
--- /dev/null
+++ b/server/src/Flaro.Core/Media/Tonabgleich.cs
@@ -0,0 +1,203 @@
+namespace Flaro.Core.Media;
+
+/// Ein Abschnitt, den zwei Tonspuren gemeinsam haben.
+///
+///
+/// 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.
+///
+public sealed record Gemeinsamkeit(double AVon, double ABis, double BVon, double BBis)
+{
+ public double Laenge => ABis - AVon;
+}
+
+/// Findet heraus, welches Stück zwei Folgen gemeinsam haben.
+///
+///
+///
+/// Das ist die ganze Idee hinter dem Überspringen. 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.
+///
+///
+/// Verglichen werden Tonabdrücke, keine Töne. 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.
+///
+///
+/// Reine Rechnung, deshalb hier. 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.
+///
+///
+public static class Tonabgleich
+{
+ /// Wie viele Bits zweier Abdrücke sich unterscheiden dürfen.
+ ///
+ ///
+ /// 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.
+ ///
+ public const int StandardBitabstand = 6;
+
+ /// Wie viele unpassende Zeitscheiben am Stück eine Lücke sein darf.
+ ///
+ ///
+ /// 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.
+ ///
+ public const int StandardLücke = 10;
+
+ /// Wie kurz ein gemeinsames Stück sein darf, um noch zu zählen.
+ ///
+ ///
+ /// Unter zehn Sekunden lohnt kein Knopf — und je kürzer die Schwelle,
+ /// desto eher findet der Abgleich zwei zufällig ähnliche Takte.
+ ///
+ public const double StandardMindestlänge = 10;
+
+ /// Sucht das längste Stück, das und teilen.
+ ///
+ ///
+ /// Wie viel Zeit eine Zeitscheibe abdeckt. Chromaprint rechnet mit
+ /// 11025 Hz und 4096 Abtastwerten bei zwei Dritteln Überlappung — das
+ /// sind rund 0,1238 Sekunden.
+ ///
+ public static Gemeinsamkeit? Finden(
+ IReadOnlyList a,
+ IReadOnlyList 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;
+ }
+
+ /// Der längste zusammenhängende Treffer bei einer Verschiebung.
+ ///
+ ///
+ /// Zusammenhängend mit Nachsicht: bis zu
+ /// 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.
+ ///
+ private static (int Start, int Dauer)? LängsterLauf(
+ IReadOnlyList a, IReadOnlyList 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;
+ }
+
+ /// Was mehrere Folgen einer Staffel gemeinsam haben.
+ ///
+ ///
+ ///
+ /// Jede gegen jede wäre zu viel. 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.
+ ///
+ ///
+ /// Die letzte Folge bekommt ihren Fund vom Vorgänger. Sie hat
+ /// keinen Nachfolger mehr, und ohne diesen Griff fehlte in jeder Staffel
+ /// genau einer Folge der Vorspann.
+ ///
+ ///
+ public static IReadOnlyDictionary ReiheAbgleichen(
+ IReadOnlyList<(TSchlüssel Schlüssel, IReadOnlyList Abdruck)> folgen,
+ double sekundenJeAbdruck,
+ double mindestlänge = StandardMindestlänge)
+ where TSchlüssel : notnull
+ {
+ var gefunden = new Dictionary();
+ 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;
+ }
+}
diff --git a/server/src/Flaro.Media/Tonabdruckleser.cs b/server/src/Flaro.Media/Tonabdruckleser.cs
new file mode 100644
index 0000000..26120b9
--- /dev/null
+++ b/server/src/Flaro.Media/Tonabdruckleser.cs
@@ -0,0 +1,173 @@
+using System.Diagnostics;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+
+namespace Flaro.Media;
+
+/// Holt den Tonabdruck eines Dateiabschnitts.
+///
+///
+///
+/// Mit fpcalc aus Chromaprint, nicht von Hand. 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.
+///
+///
+/// Fehlt das Programm, gibt es eben keine Marken. Es liegt im Abbild
+/// (libchromaprint-tools), 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.
+///
+///
+/// Nur ein Abschnitt, nicht die ganze Folge. 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.
+///
+///
+public sealed class Tonabdruckleser(ILogger log)
+{
+ /// Wie viel Zeit eine Zahl des Abdrucks abdeckt.
+ ///
+ ///
+ /// 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.
+ ///
+ public const double SekundenJeAbdruck = 4096.0 / 3.0 / 11025.0;
+
+ /// Wo fpcalc liegt — überschreibbar wie ffmpeg selbst.
+ public static string Programm =>
+ Environment.GetEnvironmentVariable("FLARO_FPCALC") is { Length: > 0 } p ? p : "fpcalc";
+
+ /// Ob das Programm überhaupt da ist.
+ ///
+ ///
+ /// 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.
+ ///
+ private static bool? _vorhanden;
+
+ public async Task 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;
+ }
+ }
+
+ /// Der Abdruck eines Abschnitts, oder eine leere Reihe.
+ ///
+ /// Ab welcher Sekunde gelesen wird.
+ /// Wie viele Sekunden gelesen werden.
+ public async Task> 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 [];
+ }
+ }
+
+ /// Liest die Ausgabe von fpcalc -raw.
+ ///
+ ///
+ /// Sie besteht aus Zeilen der Form SCHLÜSSEL=WERT; gebraucht wird
+ /// FINGERPRINT, eine mit Kommas getrennte Reihe von Zahlen. Die
+ /// Zahlen sind vorzeichenbehaftet, gemeint sind aber die Bitmuster —
+ /// deshalb über int und dann ungedeutet.
+ ///
+ public static IReadOnlyList 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(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 [];
+ }
+}
diff --git a/server/tests/Flaro.Core.Tests/TonabgleichTests.cs b/server/tests/Flaro.Core.Tests/TonabgleichTests.cs
new file mode 100644
index 0000000..37e4a9e
--- /dev/null
+++ b/server/tests/Flaro.Core.Tests/TonabgleichTests.cs
@@ -0,0 +1,165 @@
+using Flaro.Core.Media;
+
+namespace Flaro.Core.Tests;
+
+/// Was zwei Folgen gemeinsam haben.
+///
+///
+/// Mit erfundenen Abdrücken statt mit Mediendateien. 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.
+///
+public class TonabgleichTests
+{
+ /// Chromaprint mit Standardeinstellungen.
+ private const double JeAbdruck = 0.1238;
+
+ /// Eine Folge von Abdrücken, die sich nicht wiederholt.
+ ///
+ ///
+ /// Aus einem festen Startwert gerechnet, nicht gewürfelt: dieselbe
+ /// Prüfung soll morgen dasselbe Ergebnis haben.
+ ///
+ 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;
+ }
+
+ /// Hängt Abschnitte aneinander.
+ private static uint[] Aneinander(params uint[][] teile) =>
+ teile.SelectMany(t => t).ToArray();
+
+ /// Wie viele Zeitscheiben so viele Sekunden sind.
+ 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));
+ });
+ }
+
+ /// Zwei Folgen ohne Gemeinsamkeit ergeben keinen Fund.
+ ///
+ ///
+ /// Der wichtigere der beiden Fälle. Ein Vorspann, der nicht
+ /// gefunden wird, kostet einen Knopf. Ein Vorspann, der erfunden wird,
+ /// schneidet dem Zuschauer eine halbe Minute Handlung weg.
+ ///
+ [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);
+ }
+
+ /// Ein zu kurzes gemeinsames Stück zählt nicht.
+ [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);
+ }
+
+ /// Ein paar abweichende Bits machen den Fund nicht kaputt.
+ ///
+ ///
+ /// 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.
+ ///
+ [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));
+ }
+
+ /// Eine eingeblendete Ansage mitten im Vorspann reisst ihn nicht entzwei.
+ [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");
+ }
+
+ /// Eine ganze Staffel auf einmal.
+ [Test]
+ public void Jede_Folge_der_Staffel_bekommt_ihre_Stelle()
+ {
+ var vorspann = Rauschen(Scheiben(25), 51);
+ var folgen = new List<(int, IReadOnlyList)>();
+ 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}");
+ }
+ }
+
+ /// Eine einzelne Folge hat niemanden zum Vergleichen.
+ [Test]
+ public void Eine_Folge_allein_ergibt_nichts()
+ {
+ var eine = new List<(int, IReadOnlyList)> { (0, Rauschen(Scheiben(60), 81)) };
+ Assert.That(Tonabgleich.ReiheAbgleichen(eine, JeAbdruck), Is.Empty);
+ }
+}
diff --git a/server/tests/Flaro.Media.Tests/TonabdruckleserTests.cs b/server/tests/Flaro.Media.Tests/TonabdruckleserTests.cs
new file mode 100644
index 0000000..6f53c50
--- /dev/null
+++ b/server/tests/Flaro.Media.Tests/TonabdruckleserTests.cs
@@ -0,0 +1,47 @@
+using Flaro.Media;
+
+namespace Flaro.Media.Tests;
+
+/// Die Ausgabe von fpcalc -raw lesen.
+///
+///
+/// Ohne das Programm selbst. 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.
+///
+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);
+ }
+
+ /// Eine Zeitscheibe ist rund 124 Millisekunden.
+ ///
+ ///
+ /// 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.
+ ///
+ [Test]
+ public void Eine_Zeitscheibe_dauert_124_Millisekunden()
+ {
+ Assert.That(Tonabdruckleser.SekundenJeAbdruck, Is.EqualTo(0.1238).Within(0.0005));
+ }
+}