summaryrefslogtreecommitdiff
path: root/server/cmd/listen-detect/main.go
blob: 49394fde4de39d4a3b6992863d91d5fe30767cdf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// Command listen-detect reports the recurring intro and outro across a show's episode files.
//
// The detection core of the server, usable on its own so a result can be checked against real
// audio rather than only against synthetic fixtures. Point it at several episodes of one show:
//
//	listen-detect episode1.mp3 episode2.mp3 episode3.mp3 episode4.mp3
package main

import (
	"context"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/terst/listen/server/internal/decode"
	"github.com/terst/listen/server/internal/skips"
)

func main() {
	clipSeconds := flag.Int("clip-seconds", skips.DefaultClipSeconds, "window searched at each end of an episode")
	referenceSeconds := flag.Int("reference-seconds", skips.DefaultReferenceSeconds, "length of the segment looked for")
	threshold := flag.Int("confirm", skips.DefaultConfirmThreshold, "episodes that must agree (lowering this defeats the safety mechanism)")
	coarse := flag.Int("coarse", 0, "decimation for the candidate search; 0 uses the default, 1 searches at full resolution")
	flag.Parse()

	files := flag.Args()
	if len(files) == 0 {
		fmt.Fprintln(os.Stderr, "usage: listen-detect [flags] <episode file>...")
		flag.PrintDefaults()
		os.Exit(2)
	}

	cfg := skips.DefaultConfig()
	cfg.ClipSeconds = *clipSeconds
	cfg.ReferenceSeconds = *referenceSeconds
	cfg.ConfirmThreshold = *threshold
	if *coarse > 0 {
		cfg.Tuning.CoarseFactor = *coarse
	}

	episodes := make([]skips.Episode, 0, len(files))
	for _, f := range files {
		episodes = append(episodes, skips.Episode{ID: filepath.Base(f), Path: f})
	}

	decoder := decode.Decoder{}
	ctx := context.Background()

	fmt.Printf("%d episodes, %ds windows, %ds reference, confirm on %d, coarse factor %d\n\n",
		len(episodes), cfg.ClipSeconds, cfg.ReferenceSeconds, cfg.ConfirmThreshold, cfg.Tuning.CoarseFactor)

	for _, ep := range episodes {
		ms, err := decoder.DurationMs(ctx, ep.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "%s: %v\n", ep.ID, err)
			os.Exit(1)
		}
		fmt.Printf("  %-44s %s\n", ep.ID, clock(ms))
	}

	started := time.Now()
	result, err := skips.Detect(ctx, decoder, episodes, cfg)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	elapsed := time.Since(started)

	fmt.Printf("\nintro: confirmed on %d of %d episodes%s\n",
		result.IntroConfirmedOn, len(episodes), verdict(result.IntroCheck))
	fmt.Printf("outro: confirmed on %d of %d episodes%s\n",
		result.OutroConfirmedOn, len(episodes), verdict(result.OutroCheck))
	fmt.Printf("detection took %s\n\n", elapsed.Round(time.Millisecond))

	fmt.Printf("  %-44s %-14s %s\n", "episode", "skip intro to", "end early by")
	for _, o := range result.Offsets {
		fmt.Printf("  %-44s %-14s %s\n", o.EpisodeID, optional(o.IntroMs), optional(o.OutroMs))
	}

	// Non-zero when a confirmation could not verify itself, so this is usable as a check and
	// not only as a report.
	if !result.IntroCheck.OK || !result.OutroCheck.OK {
		fmt.Fprintln(os.Stderr, "\nat least one confirmation failed its self-check; no offsets published for it")
		os.Exit(1)
	}
}

// verdict describes whether the located segments agreed with each other -- a different and
// stricter question than the detection threshold, and the one that says the answer is real.
func verdict(r skips.CheckReport) string {
	if r.Pairs == 0 {
		return ""
	}
	if r.OK {
		return fmt.Sprintf("; self-check passed on %d pairs, worst r=%.4f", r.Pairs, r.WorstScore)
	}
	return fmt.Sprintf("; SELF-CHECK FAILED: %s vs %s at r=%.4f -- offsets withheld",
		r.WorstA, r.WorstB, r.WorstScore)
}

func optional(ms *int64) string {
	if ms == nil {
		return "-"
	}
	return clock(*ms)
}

// clock renders a duration to the millisecond. Not to the nearest second: an offset read back
// at second precision is out by up to half a second, which is enough to make the same audio
// look like different audio, and that cost real time the first time this was checked by hand.
func clock(ms int64) string {
	total := ms / 1000
	if h := total / 3600; h > 0 {
		return fmt.Sprintf("%d:%02d:%02d.%03d", h, total/60%60, total%60, ms%1000)
	}
	return fmt.Sprintf("%d:%02d.%03d", total/60, total%60, ms%1000)
}