Release v0.2.34
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package subsync
|
||||
|
||||
import "math/bits"
|
||||
|
||||
// signal is "somebody is speaking" as one bit per time bin.
|
||||
//
|
||||
// A bitset rather than a []bool because the search is the whole cost of this feature: a
|
||||
// two-hour film at 50ms is around 144,000 bins, and every one of ~2,400 shifts has to
|
||||
// compare all of them against every stretch candidate. Packed into words, one comparison
|
||||
// is an AND and a population count over 2,250 words instead of 144,000 byte reads, which
|
||||
// is the difference between a button that answers while somebody is still looking at the
|
||||
// menu and one they wait on. math/bits.OnesCount64 compiles to a single instruction on
|
||||
// every architecture this runs on.
|
||||
type signal struct {
|
||||
words []uint64
|
||||
bins int
|
||||
on int
|
||||
}
|
||||
|
||||
func newSignal(bins int) *signal {
|
||||
if bins < 1 {
|
||||
bins = 1
|
||||
}
|
||||
return &signal{words: make([]uint64, (bins+63)/64), bins: bins}
|
||||
}
|
||||
|
||||
// set marks the half-open bin range [from, to) as speech.
|
||||
func (s *signal) set(from, to int) {
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
if to > s.bins {
|
||||
to = s.bins
|
||||
}
|
||||
for i := from; i < to; i++ {
|
||||
word, bit := i/64, uint(i%64)
|
||||
if s.words[word]&(1<<bit) == 0 {
|
||||
s.words[word] |= 1 << bit
|
||||
s.on++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlap counts the bins where both signals are speaking, with other displaced by shift
|
||||
// bins. A positive shift means other is read later — the value to add to this signal's
|
||||
// times to bring them onto other's.
|
||||
//
|
||||
// Negative shifts are answered by swapping the two, which is the same count from the other
|
||||
// side and saves writing the word arithmetic twice in mirror image.
|
||||
func (s *signal) overlap(other *signal, shift int) int {
|
||||
if shift < 0 {
|
||||
return other.overlap(s, -shift)
|
||||
}
|
||||
wordShift, bitShift := shift/64, uint(shift%64)
|
||||
total := 0
|
||||
for i := range s.words {
|
||||
mine := s.words[i]
|
||||
if mine == 0 {
|
||||
continue
|
||||
}
|
||||
j := i + wordShift
|
||||
if j >= len(other.words) {
|
||||
break
|
||||
}
|
||||
theirs := other.words[j] >> bitShift
|
||||
// Go defines a shift of 64 or more as zero, so this term vanishes when bitShift
|
||||
// is zero rather than needing a branch of its own.
|
||||
if j+1 < len(other.words) {
|
||||
theirs |= other.words[j+1] << (64 - bitShift)
|
||||
}
|
||||
total += bits.OnesCount64(mine & theirs)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNoCues means nothing in the file looked like a subtitle.
|
||||
var ErrNoCues = errors.New("subsync: no subtitle cues found")
|
||||
|
||||
// Parse reads SRT or WebVTT.
|
||||
//
|
||||
// One parser for both because the only difference that matters here is a comma or a full
|
||||
// stop between the seconds and the milliseconds, and Emby will hand back either depending
|
||||
// on the route asked and the codec underneath. Everything a format carries that this
|
||||
// package does not need — cue identifiers, WebVTT positioning, styling blocks, the byte
|
||||
// order mark a Windows editor leaves behind — is skipped rather than rejected, because a
|
||||
// subtitle somebody is trying to fix is by definition one that is already not perfect.
|
||||
func Parse(data []byte) ([]Cue, error) {
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
text = strings.TrimPrefix(text, "\ufeff")
|
||||
|
||||
var cues []Cue
|
||||
lines := strings.Split(text, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
start, end, ok := parseTimingLine(lines[i])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
body := []string{}
|
||||
for i++; i < len(lines) && strings.TrimSpace(lines[i]) != ""; i++ {
|
||||
// A timing line with no blank line before it ends the previous cue: some
|
||||
// files in the wild are written that way, and reading the next cue's timing
|
||||
// as this one's text would put the whole file one cue out.
|
||||
if _, _, isTiming := parseTimingLine(lines[i]); isTiming {
|
||||
i--
|
||||
break
|
||||
}
|
||||
body = append(body, lines[i])
|
||||
}
|
||||
cues = append(cues, Cue{Start: start, End: end, Text: strings.Join(body, "\n")})
|
||||
}
|
||||
if len(cues) == 0 {
|
||||
return nil, ErrNoCues
|
||||
}
|
||||
return Normalise(cues), nil
|
||||
}
|
||||
|
||||
func parseTimingLine(line string) (time.Duration, time.Duration, bool) {
|
||||
before, after, ok := strings.Cut(line, "-->")
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, ok := parseTimestamp(before)
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
// WebVTT puts cue settings after the end timestamp ("align:start position:50%"), so
|
||||
// only the first field of what follows is a time.
|
||||
end, ok := parseTimestamp(strings.Fields(after)[0])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseTimestamp reads HH:MM:SS,mmm and every variation of it that turns up: a full stop
|
||||
// instead of the comma, the hours omitted as WebVTT allows, and fewer than three digits
|
||||
// after the separator.
|
||||
func parseTimestamp(field string) (time.Duration, bool) {
|
||||
text := strings.TrimSpace(field)
|
||||
if text == "" {
|
||||
return 0, false
|
||||
}
|
||||
seconds, fraction, _ := strings.Cut(strings.ReplaceAll(text, ",", "."), ".")
|
||||
|
||||
parts := strings.Split(seconds, ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
return 0, false
|
||||
}
|
||||
var total time.Duration
|
||||
units := []time.Duration{time.Hour, time.Minute, time.Second}
|
||||
units = units[len(units)-len(parts):]
|
||||
for i, part := range parts {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil || value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
total += time.Duration(value) * units[i]
|
||||
}
|
||||
|
||||
if fraction != "" {
|
||||
digits := fraction
|
||||
if len(digits) > 3 {
|
||||
digits = digits[:3]
|
||||
}
|
||||
value, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for len(digits) < 3 {
|
||||
value *= 10
|
||||
digits += "0"
|
||||
}
|
||||
total += time.Duration(value) * time.Millisecond
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
// FormatSRT writes cues back out as SubRip.
|
||||
//
|
||||
// SRT rather than the format that came in, because it is the one every player reads and
|
||||
// the one the stored-subtitle table already declares. Renumbered from one: the indices in
|
||||
// a file being repaired are frequently wrong already, and they carry no meaning worth
|
||||
// preserving.
|
||||
func FormatSRT(cues []Cue) []byte {
|
||||
var out strings.Builder
|
||||
for i, cue := range cues {
|
||||
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
|
||||
i+1, formatTimestamp(cue.Start), formatTimestamp(cue.End), cue.Text)
|
||||
}
|
||||
return []byte(out.String())
|
||||
}
|
||||
|
||||
func formatTimestamp(d time.Duration) string {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
milliseconds := d.Milliseconds()
|
||||
return fmt.Sprintf("%02d:%02d:%02d,%03d",
|
||||
milliseconds/3_600_000,
|
||||
milliseconds/60_000%60,
|
||||
milliseconds/1000%60,
|
||||
milliseconds%1000)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A film's worth of dialogue: irregularly spaced, varied line lengths, long gaps where
|
||||
// nothing is said. Regular cues would make every shift correlate with every other and
|
||||
// prove nothing about the search.
|
||||
func dialogue(count int, seed int64) []Cue {
|
||||
rng := rand.New(rand.NewSource(seed))
|
||||
cues := make([]Cue, 0, count)
|
||||
at := 12 * time.Second
|
||||
for range count {
|
||||
length := time.Duration(900+rng.Intn(2600)) * time.Millisecond
|
||||
cues = append(cues, Cue{Start: at, End: at + length, Text: "line"})
|
||||
gap := time.Duration(400+rng.Intn(4000)) * time.Millisecond
|
||||
if rng.Intn(11) == 0 {
|
||||
gap += time.Duration(6+rng.Intn(25)) * time.Second // a scene with no dialogue
|
||||
}
|
||||
at += length + gap
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
func TestAlignRecoversAKnownOffset(t *testing.T) {
|
||||
reference := dialogue(400, 7)
|
||||
for _, offset := range []time.Duration{
|
||||
-42 * time.Second, -3500 * time.Millisecond, -700 * time.Millisecond,
|
||||
2 * time.Second, 11500 * time.Millisecond, 37 * time.Second,
|
||||
} {
|
||||
t.Run(offset.String(), func(t *testing.T) {
|
||||
// The broken track is the reference pushed the wrong way, so the correction
|
||||
// that fixes it is the opposite of what was applied.
|
||||
broken := Shift(reference, -offset, 1)
|
||||
|
||||
got, err := Align(broken, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Scale != 1 {
|
||||
t.Fatalf("scale = %v, want 1 for a pure displacement", got.Scale)
|
||||
}
|
||||
if diff := (got.Offset - offset).Abs(); diff > 100*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want %v (out by %v)", got.Offset, offset, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The case a plain offset cannot fix: a track authored against one transfer and played
|
||||
// against another runs further out as the film goes on.
|
||||
func TestAlignRecoversAFrameRateStretch(t *testing.T) {
|
||||
reference := dialogue(500, 11)
|
||||
scale := 25.0 / 24
|
||||
broken := Shift(reference, 0, 1/scale)
|
||||
|
||||
got, err := Align(broken, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Scale != scale {
|
||||
t.Fatalf("scale = %v, want %v", got.Scale, scale)
|
||||
}
|
||||
|
||||
// The real test is not the reported numbers but whether applying them lands the last
|
||||
// cue where it belongs — a stretch that is right at the start and wrong at the end is
|
||||
// exactly the fault being repaired.
|
||||
fixed := Shift(broken, got.Offset, got.Scale)
|
||||
drift := (fixed[len(fixed)-1].Start - reference[len(reference)-1].Start).Abs()
|
||||
if drift > 200*time.Millisecond {
|
||||
t.Fatalf("last cue is out by %v after correction", drift)
|
||||
}
|
||||
}
|
||||
|
||||
// Timing that survives a translation. Different languages break lines differently and
|
||||
// their cues do not start on the same frame, so the alignment has to hold when the two
|
||||
// tracks agree only roughly.
|
||||
func TestAlignSurvivesATranslatedReference(t *testing.T) {
|
||||
reference := dialogue(400, 3)
|
||||
rng := rand.New(rand.NewSource(99))
|
||||
translated := make([]Cue, 0, len(reference))
|
||||
for i, cue := range reference {
|
||||
if i%9 == 0 {
|
||||
continue // a line the other track merged into its neighbour
|
||||
}
|
||||
jitter := time.Duration(rng.Intn(500)-250) * time.Millisecond
|
||||
translated = append(translated, Cue{
|
||||
Start: cue.Start + jitter,
|
||||
End: cue.End + jitter + time.Duration(rng.Intn(600))*time.Millisecond,
|
||||
Text: "ligne",
|
||||
})
|
||||
}
|
||||
|
||||
offset := -8 * time.Second
|
||||
got, err := Align(Shift(translated, -offset, 1), reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if diff := (got.Offset - offset).Abs(); diff > 300*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want %v", got.Offset, offset)
|
||||
}
|
||||
}
|
||||
|
||||
// Most of this package's job is producing no answer. A wrong correction is worse than
|
||||
// none: the viewer is told it worked and has no way to know the file is now further out.
|
||||
func TestAlignRefusesRatherThanGuess(t *testing.T) {
|
||||
reference := dialogue(400, 5)
|
||||
|
||||
t.Run("a different film", func(t *testing.T) {
|
||||
_, err := Align(dialogue(400, 6), reference, DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
if refusal.Reason == "" {
|
||||
t.Fatal("a refusal must carry something a viewer can read")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too few lines to judge", func(t *testing.T) {
|
||||
_, err := Align(dialogue(6, 1), reference, DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Evenly spaced cues fit their own reference at every multiple of the spacing, so a
|
||||
// dozen shifts score alike and none of them is the answer. This is the case the margin
|
||||
// test exists for — the score alone is a perfect 1.0 and would sail through.
|
||||
t.Run("timing that fits equally well in several places", func(t *testing.T) {
|
||||
metronome := func(offset time.Duration) []Cue {
|
||||
cues := make([]Cue, 0, 60)
|
||||
for i := range 60 {
|
||||
at := offset + time.Duration(i)*4*time.Second
|
||||
cues = append(cues, Cue{Start: at, End: at + 2*time.Second, Text: "line"})
|
||||
}
|
||||
return cues
|
||||
}
|
||||
|
||||
_, err := Align(metronome(9*time.Second), metronome(0), DefaultOptions())
|
||||
var refusal *ErrNoAlignment
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("err = %v, want a refusal", err)
|
||||
}
|
||||
if refusal.Score < 0.9 {
|
||||
t.Fatalf("score = %.2f — this case must be refused on margin, not on score",
|
||||
refusal.Score)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A sparse track is not by itself a bad one. Taking every seventeenth line of a correct
|
||||
// subtitle leaves something that still lines up in exactly one place, and refusing it
|
||||
// would cost the fix for a forced or hearing-impaired track that is merely displaced.
|
||||
// What disqualifies a sparse track is being the *reference*, which Reference handles.
|
||||
func TestAlignAcceptsASparseButUnambiguousTrack(t *testing.T) {
|
||||
reference := dialogue(400, 5)
|
||||
sparse := []Cue{}
|
||||
for i, cue := range reference {
|
||||
if i%17 == 0 {
|
||||
sparse = append(sparse, cue)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := Align(Shift(sparse, 6*time.Second, 1), reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if diff := (got.Offset + 6*time.Second).Abs(); diff > 100*time.Millisecond {
|
||||
t.Fatalf("offset = %v, want -6s", got.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
// A track already in sync must come back as no correction rather than a small nudge that
|
||||
// makes the file different for no reason.
|
||||
func TestAlignLeavesACorrectTrackAlone(t *testing.T) {
|
||||
reference := dialogue(300, 21)
|
||||
got, err := Align(reference, reference, DefaultOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("Align: %v", err)
|
||||
}
|
||||
if got.Correction() {
|
||||
t.Fatalf("a matching track reported a correction of %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShiftClampsRatherThanDroppingTheOpening(t *testing.T) {
|
||||
cues := []Cue{
|
||||
{Start: time.Second, End: 3 * time.Second, Text: "first"},
|
||||
{Start: 10 * time.Second, End: 12 * time.Second, Text: "second"},
|
||||
}
|
||||
got := Shift(cues, -30*time.Second, 1)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("cue count = %d, want 2 — no line may be lost", len(got))
|
||||
}
|
||||
if got[0].Start < 0 || got[0].End < 0 {
|
||||
t.Fatalf("negative timing survived: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Which track to measure against is the one choice this package cannot check, so the rules
|
||||
// that make a track unusable as a yardstick are pinned.
|
||||
func TestReferencePrefersTheFullestUnforcedTrack(t *testing.T) {
|
||||
candidates := [][]Cue{
|
||||
dialogue(30, 1), // 0: the broken one
|
||||
dialogue(900, 2), // 1: forced, so unusable however long
|
||||
dialogue(400, 3), // 2: the answer
|
||||
dialogue(5, 4), // 3: too short to judge anything by
|
||||
}
|
||||
forced := []bool{false, true, false, false}
|
||||
|
||||
got, ok := Reference(candidates, 0, forced, 20)
|
||||
if !ok || got != 2 {
|
||||
t.Fatalf("reference = %d, ok = %v, want index 2", got, ok)
|
||||
}
|
||||
|
||||
if _, ok := Reference(candidates[:1], 0, nil, 20); ok {
|
||||
t.Fatal("a title whose only track is the broken one has no reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReadsBothFormats(t *testing.T) {
|
||||
srt := "1\n00:00:12,500 --> 00:00:14,900\nHello there\n\n" +
|
||||
"2\n00:01:02,000 --> 00:01:04,250\nSecond line\nover two rows\n"
|
||||
vtt := "WEBVTT\n\n00:00:12.500 --> 00:00:14.900 align:start position:50%\nHello there\n\n" +
|
||||
"01:02.000 --> 01:04.250\nSecond line\nover two rows\n"
|
||||
|
||||
for name, data := range map[string]string{"srt": srt, "vtt": vtt} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(cues) != 2 {
|
||||
t.Fatalf("cue count = %d, want 2", len(cues))
|
||||
}
|
||||
if cues[0].Start != 12500*time.Millisecond || cues[0].End != 14900*time.Millisecond {
|
||||
t.Fatalf("first cue = %+v", cues[0])
|
||||
}
|
||||
if cues[1].Start != 62*time.Second {
|
||||
t.Fatalf("second cue start = %v, want 1m2s", cues[1].Start)
|
||||
}
|
||||
if cues[1].Text != "Second line\nover two rows" {
|
||||
t.Fatalf("second cue text = %q", cues[1].Text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHandlesFilesPeopleActuallyHave(t *testing.T) {
|
||||
t.Run("byte order mark and CRLF", func(t *testing.T) {
|
||||
data := "\ufeff1\r\n00:00:01,000 --> 00:00:02,000\r\nHi\r\n"
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil || len(cues) != 1 || cues[0].Text != "Hi" {
|
||||
t.Fatalf("cues = %+v, err = %v", cues, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no blank line between cues", func(t *testing.T) {
|
||||
data := "1\n00:00:01,000 --> 00:00:02,000\nOne\n2\n00:00:03,000 --> 00:00:04,000\nTwo\n"
|
||||
cues, err := Parse([]byte(data))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(cues) != 2 || cues[1].Start != 3*time.Second {
|
||||
t.Fatalf("cues = %+v", cues)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing that looks like a subtitle", func(t *testing.T) {
|
||||
if _, err := Parse([]byte("this is not a subtitle at all")); !errors.Is(err, ErrNoCues) {
|
||||
t.Fatalf("err = %v, want ErrNoCues", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFormatSRTRoundTrips(t *testing.T) {
|
||||
cues := []Cue{
|
||||
{Start: 3661500 * time.Millisecond, End: 3663000 * time.Millisecond, Text: "Late in the film"},
|
||||
{Start: 12 * time.Second, End: 14 * time.Second, Text: "Two\nrows"},
|
||||
}
|
||||
out := FormatSRT(Normalise(cues))
|
||||
if !strings.Contains(string(out), "01:01:01,500 --> 01:01:03,000") {
|
||||
t.Fatalf("timestamps not written as SubRip:\n%s", out)
|
||||
}
|
||||
|
||||
back, err := Parse(out)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(back) != 2 || back[0].Start != 12*time.Second || back[1].Text != "Late in the film" {
|
||||
t.Fatalf("round trip changed the cues: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// The search runs while somebody is looking at a menu over their film, so its cost is part
|
||||
// of whether the feature is usable at all.
|
||||
func BenchmarkAlignFeatureLength(b *testing.B) {
|
||||
reference := dialogue(1400, 1)
|
||||
broken := Shift(reference, -9*time.Second, 1)
|
||||
opts := DefaultOptions()
|
||||
b.ResetTimer()
|
||||
for range b.N {
|
||||
if _, err := Align(broken, reference, opts); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
// Package subsync fixes the timing of a subtitle by aligning it to one that is already
|
||||
// right.
|
||||
//
|
||||
// The idea is borrowed from ffsubsync and alass, which AutoSubSync drives — but not the
|
||||
// code, which is Python and Rust and needs FFmpeg to read a film's audio. This gateway's
|
||||
// image is distroless with no ffmpeg and no shell, so the audio path is closed to it. What
|
||||
// is open is the cheaper half of the same idea: both of those tools can align against a
|
||||
// *reference subtitle* instead of audio, and a household's copy of a film usually carries
|
||||
// a track that is already correct. Reduce both tracks to "somebody is speaking / nobody
|
||||
// is" on a fixed time grid and the offset is the shift where the two agree most.
|
||||
//
|
||||
// Nothing here touches the network, Emby or the database, which is what lets the whole
|
||||
// rule be tested against real subtitle text.
|
||||
package subsync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cue is one subtitle line: when it appears, when it goes, and what it says.
|
||||
//
|
||||
// The text is carried through untouched. This package changes *when* a line is shown and
|
||||
// never what it says — a resynchronised subtitle that had also been reflowed or re-escaped
|
||||
// would be impossible to tell from a corrupted one.
|
||||
type Cue struct {
|
||||
Start time.Duration
|
||||
End time.Duration
|
||||
Text string
|
||||
}
|
||||
|
||||
// Duration is where the last cue ends, which stands in for the runtime the track was
|
||||
// written against.
|
||||
func Duration(cues []Cue) time.Duration {
|
||||
var last time.Duration
|
||||
for _, cue := range cues {
|
||||
if cue.End > last {
|
||||
last = cue.End
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// Shift moves every cue by the correction Align found.
|
||||
//
|
||||
// Scale is applied before the offset, in that order, because that is the order the damage
|
||||
// happened in: a subtitle written for a 25fps transfer and played against a 23.976 one
|
||||
// runs progressively further out, and whatever fixed offset remains sits on top of the
|
||||
// stretch. A cue dragged before zero is clamped rather than dropped — it belongs to a line
|
||||
// somebody is about to hear, and a track that silently lost its opening would look like a
|
||||
// worse fault than the one being fixed.
|
||||
func Shift(cues []Cue, offset time.Duration, scale float64) []Cue {
|
||||
if scale <= 0 {
|
||||
scale = 1
|
||||
}
|
||||
out := make([]Cue, 0, len(cues))
|
||||
for _, cue := range cues {
|
||||
start := scaleDuration(cue.Start, scale) + offset
|
||||
end := scaleDuration(cue.End, scale) + offset
|
||||
if end < 0 {
|
||||
// The whole cue was pushed off the front. Keep it at zero rather than
|
||||
// discarding it: losing a line is worse than showing one early.
|
||||
start, end = 0, 0
|
||||
} else if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
out = append(out, Cue{Start: start, End: end, Text: cue.Text})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
||||
return out
|
||||
}
|
||||
|
||||
func scaleDuration(d time.Duration, scale float64) time.Duration {
|
||||
return time.Duration(float64(d) * scale)
|
||||
}
|
||||
|
||||
// FrameRateScales are the stretch factors tried alongside a plain offset.
|
||||
//
|
||||
// They are the ratios between the frame rates films are actually delivered at, because
|
||||
// the classic broken subtitle is not merely late — it is a track authored against one
|
||||
// transfer and played against another, so it drifts, and no single offset can fix it. The
|
||||
// list is deliberately short: every extra candidate is another chance for a wrong answer
|
||||
// to win by luck, and these cover the transfers that exist.
|
||||
var FrameRateScales = []float64{
|
||||
1,
|
||||
23.976 / 24, 24 / 23.976,
|
||||
25.0 / 24, 24.0 / 25,
|
||||
25 / 23.976, 23.976 / 25,
|
||||
30 / 29.97, 29.97 / 30,
|
||||
}
|
||||
|
||||
// Options tunes the search. The zero value is not usable; use DefaultOptions.
|
||||
type Options struct {
|
||||
// Bin is the width of one cell of the time grid. Fine enough that a correction is
|
||||
// worth making at all, coarse enough that two tracks written by different people
|
||||
// still land in the same cells.
|
||||
Bin time.Duration
|
||||
// MaxShift bounds the search either way. A subtitle for a different release can be a
|
||||
// minute out; beyond that the two are far more likely to be different cuts of the
|
||||
// film, where a confident answer would be a wrong one.
|
||||
MaxShift time.Duration
|
||||
// MinCues is how much evidence is needed on each side before an answer is offered.
|
||||
MinCues int
|
||||
// MinScore is the share of the shorter track's speech that must line up.
|
||||
MinScore float64
|
||||
// MinMargin is how far the winning shift must beat the best rival outside its own
|
||||
// peak. This is what separates a real alignment from a track that correlates weakly
|
||||
// with everything, which is what a wrong reference looks like.
|
||||
MinMargin float64
|
||||
// Scales are the stretch candidates tried. Empty means offset only.
|
||||
Scales []float64
|
||||
}
|
||||
|
||||
// DefaultOptions is the tuning the gateway uses.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Bin: 50 * time.Millisecond,
|
||||
MaxShift: 60 * time.Second,
|
||||
MinCues: 20,
|
||||
MinScore: 0.45,
|
||||
MinMargin: 0.10,
|
||||
Scales: FrameRateScales,
|
||||
}
|
||||
}
|
||||
|
||||
// Result describes a correction.
|
||||
type Result struct {
|
||||
// Offset is what to add to every cue, after Scale.
|
||||
Offset time.Duration
|
||||
// Scale is the stretch applied first. 1 means the timing was merely displaced.
|
||||
Scale float64
|
||||
// Score is the share of the shorter track's speech that lines up once corrected —
|
||||
// how much the two agree, not how confident we are.
|
||||
Score float64
|
||||
// Margin is how far ahead of the best rival alignment this one finished. A high
|
||||
// score with no margin is a track that matches everything, which matches nothing.
|
||||
Margin float64
|
||||
}
|
||||
|
||||
// Correction reports whether this result actually changes anything a viewer would see.
|
||||
func (r Result) Correction() bool {
|
||||
return r.Offset.Abs() >= 100*time.Millisecond || r.Scale != 1
|
||||
}
|
||||
|
||||
// String renders the correction the way the console and the log want it.
|
||||
func (r Result) String() string {
|
||||
seconds := r.Offset.Seconds()
|
||||
out := fmt.Sprintf("%+.2fs", seconds)
|
||||
if r.Scale != 1 {
|
||||
out += fmt.Sprintf(" at %.4f×", r.Scale)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ErrNoAlignment is returned when the two tracks cannot be aligned with confidence.
|
||||
//
|
||||
// Most of this package is about producing this rather than a number. A subtitle nudged to
|
||||
// the wrong place is worse than one left alone: the viewer asked for a fix, would be told
|
||||
// it worked, and would have no way of knowing the file they now have is further out than
|
||||
// the one they started with.
|
||||
type ErrNoAlignment struct {
|
||||
// Reason is safe to show a viewer.
|
||||
Reason string
|
||||
// Score and Margin are what the search actually found, for the log.
|
||||
Score float64
|
||||
Margin float64
|
||||
}
|
||||
|
||||
func (e *ErrNoAlignment) Error() string {
|
||||
return fmt.Sprintf("subsync: %s (score %.2f, margin %.2f)", e.Reason, e.Score, e.Margin)
|
||||
}
|
||||
|
||||
// Align finds the correction that brings broken onto reference.
|
||||
//
|
||||
// The reference is assumed correct; nothing here checks that, because nothing could. The
|
||||
// caller chooses it, and choosing badly is the one failure this package cannot detect —
|
||||
// which is why the margin test exists, since a wrong reference tends to match everything
|
||||
// equally rather than matching one shift particularly well.
|
||||
func Align(broken, reference []Cue, opts Options) (Result, error) {
|
||||
if opts.Bin <= 0 {
|
||||
opts = DefaultOptions()
|
||||
}
|
||||
if len(broken) < opts.MinCues || len(reference) < opts.MinCues {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "there are not enough lines in one of these subtitles to compare them",
|
||||
}
|
||||
}
|
||||
|
||||
scales := opts.Scales
|
||||
if len(scales) == 0 {
|
||||
scales = []float64{1}
|
||||
}
|
||||
|
||||
// The reference is rasterised once; the broken track is rasterised per stretch
|
||||
// candidate, which is a handful of times rather than once per shift.
|
||||
ref := rasterise(reference, opts.Bin, 1)
|
||||
maxShiftBins := int(opts.MaxShift / opts.Bin)
|
||||
|
||||
best := Result{Scale: 1}
|
||||
bestBins := 0
|
||||
found := false
|
||||
// Rivals holds the best score at each shift far enough from the winner to be a
|
||||
// different answer rather than the same peak's shoulder.
|
||||
var runnerUp float64
|
||||
|
||||
for _, scale := range scales {
|
||||
signal := rasterise(broken, opts.Bin, scale)
|
||||
if signal.on == 0 {
|
||||
continue
|
||||
}
|
||||
floor := min(signal.on, ref.on)
|
||||
if floor == 0 {
|
||||
continue
|
||||
}
|
||||
for shift := -maxShiftBins; shift <= maxShiftBins; shift++ {
|
||||
score := float64(signal.overlap(ref, shift)) / float64(floor)
|
||||
switch {
|
||||
case !found || score > best.Score:
|
||||
// The old winner becomes a rival only if it is a different answer.
|
||||
if found && farApart(bestBins, shift, best.Scale, scale, opts.Bin) {
|
||||
runnerUp = max(runnerUp, best.Score)
|
||||
}
|
||||
found = true
|
||||
best = Result{
|
||||
Offset: time.Duration(shift) * opts.Bin,
|
||||
Scale: scale,
|
||||
Score: score,
|
||||
}
|
||||
bestBins = shift
|
||||
case farApart(bestBins, shift, best.Scale, scale, opts.Bin):
|
||||
runnerUp = max(runnerUp, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Result{}, &ErrNoAlignment{Reason: "these subtitles have no speech in common"}
|
||||
}
|
||||
best.Margin = best.Score - runnerUp
|
||||
if best.Score < opts.MinScore {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "these two subtitles are too different to line up — they may be for " +
|
||||
"different cuts of this title",
|
||||
Score: best.Score, Margin: best.Margin,
|
||||
}
|
||||
}
|
||||
if best.Margin < opts.MinMargin {
|
||||
return Result{}, &ErrNoAlignment{
|
||||
Reason: "no single timing fits better than the others, so the result would be " +
|
||||
"a guess",
|
||||
Score: best.Score, Margin: best.Margin,
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// farApart says whether two candidates are different answers rather than two samples of
|
||||
// one peak. Alignment produces a broad hill either side of the true shift, so the shifts
|
||||
// immediately around the winner are not rivals — treating them as rivals would collapse
|
||||
// every margin to nearly zero and refuse every correct answer.
|
||||
func farApart(bestBins, shift int, bestScale, scale float64, bin time.Duration) bool {
|
||||
if bestScale != scale {
|
||||
return true
|
||||
}
|
||||
guard := int(time.Second / bin)
|
||||
return abs(shift-bestBins) > guard
|
||||
}
|
||||
|
||||
func abs(v int) int {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Reference picks which of the tracks on offer to align against.
|
||||
//
|
||||
// The rules are about what makes a *usable* yardstick, not what makes a good subtitle. A
|
||||
// forced track carries only the lines that are foreign to the film's own audio, so it is
|
||||
// mostly silence and would correlate with almost any shift; a track with very few cues is
|
||||
// the same problem in a different shape. Longest wins because coverage is what the
|
||||
// correlation is measured against. It returns the index so the caller can name the track
|
||||
// it used, which is the one thing a viewer needs to judge the answer.
|
||||
func Reference(candidates [][]Cue, exclude int, forced []bool, minCues int) (int, bool) {
|
||||
best, bestCount := -1, 0
|
||||
for i, cues := range candidates {
|
||||
if i == exclude || len(cues) < minCues {
|
||||
continue
|
||||
}
|
||||
if i < len(forced) && forced[i] {
|
||||
continue
|
||||
}
|
||||
if len(cues) > bestCount {
|
||||
best, bestCount = i, len(cues)
|
||||
}
|
||||
}
|
||||
return best, best >= 0
|
||||
}
|
||||
|
||||
// rasterise turns cues into one bit per time bin: is anybody speaking in this cell.
|
||||
//
|
||||
// The text is thrown away deliberately. Two subtitles for one film are in different
|
||||
// languages as often as not, so the only thing they can be compared on is *when* lines
|
||||
// happen — which turns out to be plenty, because dialogue rhythm is a property of the
|
||||
// film rather than of the translation.
|
||||
func rasterise(cues []Cue, bin time.Duration, scale float64) *signal {
|
||||
end := time.Duration(0)
|
||||
for _, cue := range cues {
|
||||
if e := scaleDuration(cue.End, scale); e > end {
|
||||
end = e
|
||||
}
|
||||
}
|
||||
sig := newSignal(int(end/bin) + 2)
|
||||
for _, cue := range cues {
|
||||
start := scaleDuration(cue.Start, scale)
|
||||
stop := scaleDuration(cue.End, scale)
|
||||
if stop <= start {
|
||||
// A zero-length or reversed cue still says a line happened here.
|
||||
stop = start + bin
|
||||
}
|
||||
sig.set(int(start/bin), int(stop/bin))
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// Normalise tidies a parsed track before it is compared or written back.
|
||||
//
|
||||
// Cues out of order, or overlapping, are ordinary in files people have edited by hand, and
|
||||
// both would put speech in the wrong cell.
|
||||
func Normalise(cues []Cue) []Cue {
|
||||
out := make([]Cue, 0, len(cues))
|
||||
for _, cue := range cues {
|
||||
if strings.TrimSpace(cue.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if cue.End < cue.Start {
|
||||
cue.Start, cue.End = cue.End, cue.Start
|
||||
}
|
||||
out = append(out, cue)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Start < out[j].Start })
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user