diff --git a/server/src/Flaro.Api/Streamkoordinator.cs b/server/src/Flaro.Api/Streamkoordinator.cs
index 2806ddd..7f2b464 100644
--- a/server/src/Flaro.Api/Streamkoordinator.cs
+++ b/server/src/Flaro.Api/Streamkoordinator.cs
@@ -152,19 +152,31 @@ public sealed class Streamkoordinator(
// Beim Umkodieren entfällt das: dort wird das Bild neu gerechnet und
// schneidet auf die Sekunde genau. Dann vorzurunden hiesse, ohne Not
// ein Stück zurückzuspringen.
+ // **Gesprungen wird auf den Wunsch, gemeldet wird das Schlüsselbild.**
+ //
+ // Beim Kopieren springt ffmpeg ungenau — siehe `HlsArgumente` — und
+ // landet damit auf dem Schlüsselbild vor dem Wunsch. Bild und Ton
+ // fangen dort gemeinsam an; das ist die Zusage, und sie hängt an
+ // keiner Rechnung hier.
+ //
+ // Wo genau das war, sagt die Suche. Sie ist dafür da, dem Spieler
+ // eine ehrliche Uhr zu geben — nicht dafür, die Ausrichtung
+ // herzustellen. Vergreift sie sich, geht die Uhr falsch; Bild und Ton
+ // bleiben zusammen. Umgekehrt wäre es ein schlechter Tausch: einen
+ // Sprungpunkt auf die Millisekunde zu treffen, ist zu viel verlangt.
var gewuenscht = Math.Max(0, ab);
- var anfang = plan.BildKopieren
+ var gemeldet = plan.BildKopieren
? await schluesselbilder.DavorAsync(datei.Path, gewuenscht, ct)
: gewuenscht;
var anfrage = new Stromanfrage(
- datei.Path, ordner, gelesen, plan, tonspur, anfang, Hardware(), stufe, tonversatz);
+ datei.Path, ordner, gelesen, plan, tonspur, gewuenscht, Hardware(), stufe, tonversatz);
// **Die tatsächlich benutzte Spur, nicht die gewünschte.** Sonst
// stünde im Menü bei keiner ein Haken, obwohl eine davon läuft — und
// bei einer Spur, die es nicht mehr gibt, bei der falschen.
var sitzung = new Stromsitzung(
- kennung, dateiId, benutzer, ordner, anfang,
+ kennung, dateiId, benutzer, ordner, gemeldet,
plan.Hoehe, HlsArgumente.Tonspur(gelesen, tonspur)?.Index, plan.Art, clock.GetUtcNow());
var prozess = Starten(anfrage, sitzung);
diff --git a/server/src/Flaro.Media/HlsArgumente.cs b/server/src/Flaro.Media/HlsArgumente.cs
index 2304e28..4b59215 100644
--- a/server/src/Flaro.Media/HlsArgumente.cs
+++ b/server/src/Flaro.Media/HlsArgumente.cs
@@ -106,8 +106,28 @@ public static class HlsArgumente
if (anfrage.Ab > 0)
{
+ // **Beim Kopieren wird ungenau gesprungen, und das mit Absicht.**
+ //
+ // Genau gesprungen schneidet ffmpeg den Ton auf die Sekunde, das
+ // kopierte Bild aber nicht — es kann erst beim Schlüsselbild davor
+ // anfangen. Dazwischen liegt Bild ohne Ton, und jeder Spieler darf
+ // diese Lücke deuten, wie er will; hls.js schiebt den Ton nach
+ // vorn. Gemessen an einer Datei mit Schlüsselbildern alle fünf
+ // Sekunden: bei einem Sprung auf 17 begann der Ton erst bei 1,96,
+ // bei 15,014 sogar erst bei 4,98.
+ //
+ // `-noaccurate_seek` lässt auch den Ton beim Schlüsselbild
+ // anfangen. Dieselben Sprünge, gemessen: 0,004 und 0,015 — und
+ // zwar bei *jedem* Wert, nicht nur bei gut getroffenen. Das ist
+ // der Unterschied zum Runden auf das Schlüsselbild: das muss auf
+ // die Millisekunde stimmen, sonst wird es schlimmer als vorher.
+ //
+ // Beim Umkodieren nicht: dort wird das Bild neu gerechnet und
+ // schneidet ohnehin genau.
+ if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek");
+
argumente.Add("-ss");
- argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
+ argumente.Add(Sekunden(anfrage.Ab));
}
argumente.Add("-i");
@@ -122,11 +142,12 @@ public static class HlsArgumente
if (zweiteEingabe)
{
argumente.Add("-itsoffset");
- argumente.Add(anfrage.Tonversatz.ToString("0.###", CultureInfo.InvariantCulture));
+ argumente.Add(Sekunden(anfrage.Tonversatz));
if (anfrage.Ab > 0)
{
+ if (anfrage.Plan.BildKopieren) argumente.Add("-noaccurate_seek");
argumente.Add("-ss");
- argumente.Add(anfrage.Ab.ToString("0.###", CultureInfo.InvariantCulture));
+ argumente.Add(Sekunden(anfrage.Ab));
}
argumente.Add("-i");
@@ -196,6 +217,18 @@ public static class HlsArgumente
/// Haken, obwohl eine davon lief.
///
///
+ /// Eine Sekundenangabe für ffmpeg — mit Punkt und voller Genauigkeit.
+ ///
+ ///
+ /// Sechs Nachkommastellen, nicht drei. Auf drei gerundet kann ein
+ /// Sprungpunkt um eine Millisekunde zu klein werden, und eine Millisekunde
+ /// entscheidet, ob ffmpeg das gemeinte Schlüsselbild erwischt oder das
+ /// davor. Gemessen: bei 15,015 begann der Ton nach 0,004 s, bei 15,014
+ /// nach 4,98 s.
+ ///
+ private static string Sekunden(double wert) =>
+ wert.ToString("0.######", CultureInfo.InvariantCulture);
+
public static ProbedStream? Tonspur(IReadOnlyList spuren, int? gewuenscht)
{
var alle = spuren.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
diff --git a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
index efa235f..f4e446b 100644
--- a/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
+++ b/server/tests/Flaro.Media.Tests/HlsLaufTests.cs
@@ -192,6 +192,39 @@ public class HlsLaufTests
Assert.That(Directory.GetFiles(ziel, "*.m4s"), Is.Not.Empty);
}
+ /// Bild und Ton fangen zusammen an — bei jedem Sprungwert.
+ ///
+ ///
+ ///
+ /// Das ist der Test, der gefehlt hat. Die Quelle hat genau ein
+ /// Schlüsselbild, bei null; jeder Sprung landet also mitten in der
+ /// Bildgruppe — der schlimmste Fall. Genau gesprungen schnitte ffmpeg den
+ /// Ton auf die Sekunde, das kopierte Bild aber nicht, und dazwischen läge
+ /// Bild ohne Ton.
+ ///
+ ///
+ /// Zuerst hatte ich dafür den Sprung auf das Schlüsselbild gerundet. Das
+ /// war zu scharf geschliffen: eine Millisekunde zu klein, und ffmpeg nimmt
+ /// das Schlüsselbild davor — gemessen wurden aus 0,004 s Abstand dann
+ /// 4,98. `-noaccurate_seek` braucht diese Genauigkeit nicht.
+ ///
+ ///
+ [TestCase(0.0)]
+ [TestCase(1.5)]
+ [TestCase(3.2)]
+ [TestCase(4.0)]
+ [TestCase(6.75)]
+ public void Bild_und_Ton_fangen_bei_jedem_Sprung_zusammen_an(double ab)
+ {
+ var quelle = Path.Combine(_ordner, "lang.mkv");
+ Lang(quelle);
+
+ var (bild, ton) = Anfang(Laufen(quelle, ab: ab));
+
+ Assert.That(Math.Abs(bild - ton), Is.LessThan(0.2),
+ $"Sprung auf {ab}: Bild beginnt bei {bild}, Ton bei {ton} — das hört man.");
+ }
+
/// Bild und Ton fangen an derselben Stelle an.
///
///
diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts
index c150269..898c140 100644
--- a/web/app/src/icons.ts
+++ b/web/app/src/icons.ts
@@ -95,6 +95,9 @@ export const icons = {
* reserviert, was etwas wegnimmt. Wer meldet, dass ein Plakat nicht passt,
* löst keinen Alarm aus. */
fahne: rahmen(''),
+
+ /** Tonversatz — zwei Pfeile, die auseinandergehen. */
+ versatz: rahmen(''),
} as const;
export type IconName = keyof typeof icons;
diff --git a/web/app/src/spieler.ts b/web/app/src/spieler.ts
index eea0446..2750b60 100644
--- a/web/app/src/spieler.ts
+++ b/web/app/src/spieler.ts
@@ -85,12 +85,13 @@ export function geraeteprofil(): {
/** Wie oft der Stand zum Server geht. */
const MELDEABSTAND = 10_000;
-/** In welchen Schritten sich der Tonversatz verstellen lässt. */
-const VERSATZSCHRITT = 100;
-
-/** Wie weit in beide Richtungen. Darüber hinaus ist es kein Versatz mehr,
- * sondern eine kaputte Datei. */
-const VERSATZGRENZE = 5000;
+/** Die Stufen des Tonversatzes in Millisekunden.
+ *
+ * **Nicht jeder Hunderter von −5000 bis +5000.** Das wären einhunderteins
+ * Einträge für etwas, das man im Jahr dreimal braucht. Diese elf decken ab,
+ * was vorkommt — und wer mehr braucht, hat keine Einstellung zu wenig,
+ * sondern eine kaputte Datei. */
+const VERSAETZE = [-1000, -500, -300, -200, -100, 0, 100, 200, 300, 500, 1000];
/** Wo gemerkte Tonversätze liegen — je Datei, in diesem Browser.
*
@@ -132,10 +133,11 @@ export function tonspracheMerken(sprache: string): void {
try { localStorage.setItem(SPRACHSCHLUESSEL, sprache); } catch { /* Privates Fenster. */ }
}
-/** „läuft mit", „200 ms später", „300 ms früher". */
+/** „Ohne Versatz", „Ton 200 ms später", „Ton 1 s früher". */
function versatzWort(ms: number): string {
- if (ms === 0) return 'läuft mit dem Bild';
- return ms > 0 ? `${ms} ms später` : `${-ms} ms früher`;
+ if (ms === 0) return 'Ohne Versatz';
+ const betrag = Math.abs(ms) >= 1000 ? `${Math.abs(ms) / 1000} s` : `${Math.abs(ms)} ms`;
+ return `Ton ${betrag} ${ms > 0 ? 'später' : 'früher'}`;
}
/** Die Stufen, die im Menü stehen. Muss zu `Stromentscheid.Stufen` passen. */
@@ -373,6 +375,7 @@ export function spieler(anfrage: Spieleranfrage): void {
const tonmenue = menue('tonspur', 'Tonspur');
const textmenue = menue('untertitel', 'Untertitel');
const stufenmenue = menue('regler', 'Qualität');
+ const versatzmenue = menue('versatz', 'Tonversatz');
const links = document.createElement('div');
links.className = 'pl-gruppe';
@@ -380,7 +383,8 @@ export function spieler(anfrage: Spieleranfrage): void {
const rechts = document.createElement('div');
rechts.className = 'pl-gruppe';
- rechts.append(rest, tonmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild);
+ rechts.append(
+ rest, tonmenue.knopf, versatzmenue.knopf, textmenue.knopf, stufenmenue.knopf, vollbild);
const unten = document.createElement('div');
unten.className = 'pl-unten';
@@ -647,30 +651,25 @@ export function spieler(anfrage: Spieleranfrage): void {
}))
: [{ text: 'Diese Datei hat keinen Ton.', gewaehlt: false, tun: () => {} }];
- // **Der Versatz steht im Tonmenü, nicht in einem eigenen.** Wer ihn
- // sucht, sucht ihn beim Ton — und ein vierter Knopf in der Leiste wäre
- // einer für etwas, das man im Jahr dreimal braucht.
+ tonmenue.setzen(spuren);
+
+ // **Der Versatz hat ein eigenes Menü.** Er stand vorher unter den
+ // Tonspuren, und dort las er sich wie eine: „Später (+100 ms)" sah aus
+ // wie der Name einer Spur. Eine Einstellung gehört nicht in eine Liste
+ // von Inhalten.
//
- // Zwei Schritte und ein Zurück statt einer Liste: eine Aufzählung von
- // −5000 bis +5000 in Hundertern wären einhunderteins Einträge, und man
- // sucht ohnehin nicht einen Wert, sondern rückt ihn, bis es passt.
- const verstellen = (um: number): void => {
- tonversatz = Math.max(-VERSATZGRENZE, Math.min(VERSATZGRENZE, tonversatz + um));
- versatzMerken(anfrage.dateiId, tonversatz);
- void stromAufbauen(filmzeit());
- };
-
- // Ohne Tonspur gibt es nichts zu verschieben.
- const versatzpunkte = toene.length === 0 ? [] : [
- { text: `Ton ${versatzWort(tonversatz)}`, gewaehlt: false, tun: () => {} },
- { text: `Später (+${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(VERSATZSCHRITT) },
- { text: `Früher (−${VERSATZSCHRITT} ms)`, gewaehlt: false, tun: () => verstellen(-VERSATZSCHRITT) },
- ...(tonversatz !== 0
- ? [{ text: 'Versatz zurücksetzen', gewaehlt: false, tun: () => verstellen(-tonversatz) }]
- : []),
- ];
-
- tonmenue.setzen([...spuren, ...versatzpunkte]);
+ // Feste Stufen statt zweier Tasten, wie bei der Qualität: so zeigt der
+ // Haken, wo man steht, und man trifft einen Wert, statt sich
+ // hinzutasten — jeder Schritt baut den Strom neu auf.
+ versatzmenue.setzen(VERSAETZE.map(ms => ({
+ text: versatzWort(ms),
+ gewaehlt: ms === tonversatz,
+ tun: () => {
+ tonversatz = ms;
+ versatzMerken(anfrage.dateiId, ms);
+ void stromAufbauen(filmzeit());
+ },
+ })));
const texte = auskunft?.subtitleStreams ?? [];
textmenue.setzen(