// 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) }