audio.go
4292 bytes
1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/binary"
7 "fmt"
8 "math"
9 "math/rand"
10 "os"
11 "os/exec"
12 "strconv"
13 "strings"
14 "time"
15)
16
17const (
18 sampleRate = 44100
19 searchWindow = 30 // seconds of file decoded to find a good start point
20)
21
22// decodeWindow decodes maxSec seconds of a file starting at offsetSec into
23// mono s16le PCM.
24func decodeWindow(path string, offsetSec, maxSec int) ([]int16, error) {
25 cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
26 "-ss", strconv.Itoa(offsetSec),
27 "-i", path,
28 "-t", strconv.Itoa(maxSec),
29 "-ac", "1",
30 "-ar", strconv.Itoa(sampleRate),
31 "-f", "s16le", "-")
32
33 var out bytes.Buffer
34 cmd.Stdout = &out
35 if err := cmd.Run(); err != nil {
36 return nil, fmt.Errorf("ffmpeg decode: %w", err)
37 }
38
39 data := out.Bytes()
40 n := len(data) / 2
41 samples := make([]int16, n)
42 for i := 0; i < n; i++ {
43 samples[i] = int16(binary.LittleEndian.Uint16(data[2*i:]))
44 }
45 return samples, nil
46}
47
48// findStart returns the sample offset where the RMS level first crosses the
49// average RMS of the decoded window, so silent stretches are skipped. It
50// scans in 10ms windows and falls back to 0 if the audio is silent throughout.
51func findStart(samples []int16) int {
52 win := sampleRate / 100 // 10ms
53
54 rmsVals := make([]float64, 0, len(samples)/win)
55 var sum float64
56 for i := 0; i+win <= len(samples); i += win {
57 v := rms(samples[i : i+win])
58 rmsVals = append(rmsVals, v)
59 sum += v
60 }
61 if len(rmsVals) == 0 {
62 return 0
63 }
64 avg := sum / float64(len(rmsVals))
65 for i, v := range rmsVals {
66 if v >= avg {
67 return i * win
68 }
69 }
70 return 0
71}
72
73// rms computes the root-mean-square amplitude of a sample slice.
74func rms(s []int16) float64 {
75 if len(s) == 0 {
76 return 0
77 }
78 var sum int64
79 for _, v := range s {
80 sum += int64(v) * int64(v)
81 }
82 return math.Sqrt(float64(sum) / float64(len(s)))
83}
84
85// duration returns the total length of path in seconds.
86func duration(path string) (int, error) {
87 cmd := exec.Command("ffprobe", "-v", "error",
88 "-show_entries", "format=duration",
89 "-of", "default=noprint_wrappers=1:nokey=1", path)
90 var out bytes.Buffer
91 cmd.Stdout = &out
92 if err := cmd.Run(); err != nil {
93 return 0, fmt.Errorf("ffprobe: %w", err)
94 }
95 var d float64
96 if _, err := fmt.Sscanf(strings.TrimSpace(out.String()), "%f", &d); err != nil {
97 return 0, fmt.Errorf("parse duration: %w", err)
98 }
99 return int(d), nil
100}
101
102// pickStart chooses a random searchWindow-second window within the song and
103// returns the offset (in seconds) of a point in it where the volume is above
104// the window's average, so silent stretches are skipped.
105func pickStart(songPath string) (int, error) {
106 total, err := duration(songPath)
107 if err != nil {
108 return 0, err
109 }
110
111 maxOffset := total - searchWindow
112 if maxOffset < 0 {
113 maxOffset = 0
114 }
115 offset := rand.Intn(maxOffset + 1)
116
117 samples, err := decodeWindow(songPath, offset, searchWindow)
118 if err != nil {
119 return 0, err
120 }
121 if len(samples) == 0 {
122 return 0, fmt.Errorf("no audio decoded from %s", songPath)
123 }
124
125 start := findStart(samples)
126 return offset + start/sampleRate, nil
127}
128
129// generateSegment decodes `seconds` of audio starting at startSec into an
130// in-memory mono WAV buffer at sampleRate Hz. The bytes can be played locally
131// with aplay (TUI) or served over HTTP (web UI).
132func generateSegment(songPath string, startSec, seconds int) ([]byte, error) {
133 cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
134 "-ss", strconv.Itoa(startSec), "-t", strconv.Itoa(seconds),
135 "-i", songPath, "-ac", "1", "-ar", strconv.Itoa(sampleRate),
136 "-f", "wav", "-")
137 var out bytes.Buffer
138 cmd.Stdout = &out
139 if err := cmd.Run(); err != nil {
140 return nil, fmt.Errorf("ffmpeg encode: %w", err)
141 }
142 return out.Bytes(), nil
143}
144
145// playSegment decodes a segment and plays it with aplay (used by the TUI).
146func playSegment(songPath string, startSec, seconds int) error {
147 wav, err := generateSegment(songPath, startSec, seconds)
148 if err != nil {
149 return err
150 }
151 // timeout to fix aplay hang
152 timeout := time.Duration(seconds)*time.Second + 200*time.Millisecond
153 ctx, cancel := context.WithTimeout(context.Background(), timeout)
154 defer cancel()
155 cmd := exec.CommandContext(ctx, "aplay", "-q")
156 cmd.Stdin = bytes.NewReader(wav)
157 cmd.Stdout = os.Stderr
158 cmd.Stderr = os.Stderr
159 return cmd.Run()
160}