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)); + } +}