summaryrefslogtreecommitdiff
path: root/server/internal/decode/decode.go
blob: 85dde37661b1490afebfda3cd10f31e02d2e2dc8 (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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// Package decode turns an audio file into the mono PCM the detector works on.
package decode

import (
	"bytes"
	"context"
	"encoding/binary"
	"fmt"
	"math"
	"os/exec"
	"strconv"
	"strings"
	"time"
)

// TargetSampleRate is what every clip is decoded to, whatever the file was encoded at.
//
// 8kHz is plenty of signal for a spoken or musical sting, and a fixed rate means the detector
// never has to care what an episode's own encoding was.
const TargetSampleRate = 8000

// Clip is decoded mono audio and where it starts in the file it came from.
type Clip struct {
	Samples    []float64
	SampleRate int
	StartMs    int64
}

// Decoder decodes audio by shelling out to ffmpeg, the same way podkit shells out to yt-dlp.
//
// ffmpeg rather than a Go decoder: it handles every container and codec a podcast CDN might
// serve, including the ones that turn up once. Replacing the phone's MediaCodec with it is the
// only platform-specific part of moving detection to a server -- and unlike MediaCodec it has
// no per-process Java heap to run out of, which is what made whole-episode decoding impossible
// on the phone.
type Decoder struct {
	// FFmpegDir is the directory holding ffmpeg and ffprobe. Empty means look them up on PATH.
	FFmpegDir string
	// Timeout bounds a single decode. Zero means DefaultTimeout.
	Timeout time.Duration
}

// DefaultTimeout is generous on purpose: decoding a three-hour episode is legitimate work, and
// the server has no equivalent of the ten-minute ceiling that killed this on the phone.
const DefaultTimeout = 10 * time.Minute

// Range decodes durationMs of audio starting at startMs, at [TargetSampleRate]. A durationMs of
// zero decodes to the end of the file.
//
// Decoding a range rather than always the whole file is what lets a caller verify one candidate
// segment at full resolution without holding an entire episode in memory.
func (d Decoder) Range(ctx context.Context, path string, startMs, durationMs int64) (*Clip, error) {
	return d.RangeAt(ctx, path, startMs, durationMs, TargetSampleRate)
}

// RangeAt is [Range] at a chosen sample rate.
//
// Whole-episode work needs a far lower rate than matching does: an hour at 8kHz is 29 million
// samples, and at 250Hz it is 900 thousand -- the difference between a correlation that is
// impossible and one that takes seconds. ffmpeg does the resampling, so the low rate costs
// nothing extra here rather than being decimated afterwards from audio that had to be decoded
// at full rate first.
func (d Decoder) RangeAt(ctx context.Context, path string, startMs, durationMs int64, sampleRate int) (*Clip, error) {
	if sampleRate <= 0 {
		sampleRate = TargetSampleRate
	}
	args := []string{"-nostdin", "-hide_banner", "-loglevel", "error"}
	if startMs > 0 {
		args = append(args, "-ss", millisToTimestamp(startMs))
	}
	args = append(args, "-i", path)
	if durationMs > 0 {
		args = append(args, "-t", millisToTimestamp(durationMs))
	}
	// f64le straight out, so nothing downstream has to reinterpret or rescale it.
	args = append(args, "-vn", "-ac", "1", "-ar", strconv.Itoa(sampleRate), "-f", "f64le", "-")

	timeout := d.Timeout
	if timeout == 0 {
		timeout = DefaultTimeout
	}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	cmd := exec.CommandContext(ctx, d.tool("ffmpeg"), args...)
	var out, errOut bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &errOut
	if err := cmd.Run(); err != nil {
		return nil, fmt.Errorf("decode %s: %w: %s", path, err, strings.TrimSpace(errOut.String()))
	}

	samples, err := readFloat64LE(out.Bytes())
	if err != nil {
		return nil, fmt.Errorf("decode %s: %w", path, err)
	}
	return &Clip{Samples: samples, SampleRate: sampleRate, StartMs: startMs}, nil
}

// DurationMs reports a file's real length, which is routinely not what its feed claims --
// Radiolab declares one episode at 22:05 and serves 27 to 30 minutes of it.
func (d Decoder) DurationMs(ctx context.Context, path string) (int64, error) {
	cmd := exec.CommandContext(ctx, d.tool("ffprobe"),
		"-v", "error", "-show_entries", "format=duration",
		"-of", "default=noprint_wrappers=1:nokey=1", path)
	out, err := cmd.Output()
	if err != nil {
		return 0, fmt.Errorf("probe %s: %w", path, err)
	}
	seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
	if err != nil {
		return 0, fmt.Errorf("probe %s: unparseable duration %q: %w", path, out, err)
	}
	return int64(math.Round(seconds * 1000)), nil
}

func (d Decoder) tool(name string) string {
	if d.FFmpegDir == "" {
		return name
	}
	return strings.TrimSuffix(d.FFmpegDir, "/") + "/" + name
}

func readFloat64LE(raw []byte) ([]float64, error) {
	if len(raw)%8 != 0 {
		return nil, fmt.Errorf("got %d bytes, not a whole number of float64 samples", len(raw))
	}
	out := make([]float64, len(raw)/8)
	for i := range out {
		out[i] = math.Float64frombits(binary.LittleEndian.Uint64(raw[i*8:]))
	}
	return out, nil
}

func millisToTimestamp(ms int64) string {
	return fmt.Sprintf("%d.%03d", ms/1000, ms%1000)
}