351997d2b5dd27e0635c3106d2b55bf9e52f868d
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/audio.go b/audio.go
2index 6b77026feadab482080c3bc67f5b5a8a7736fdd4..66578dfae21e668c259ec50a69b48121645eda3d 100644
3--- a/audio.go
4+++ b/audio.go
5@@ -5,19 +5,23 @@ import (
6 "encoding/binary"
7 "fmt"
8 "math"
9+ "math/rand"
10 "os"
11 "os/exec"
12 "path/filepath"
13+ "strings"
14 )
15
16 const (
17- sampleRate = 44100
18- introWindow = 30 // seconds of file decoded for start detection
19+ sampleRate = 44100
20+ searchWindow = 30 // seconds of file decoded to find a good start point
21 )
22
23-// decodeIntro decodes the first maxSec seconds of a file into mono s16le PCM.
24-func decodeIntro(path string, maxSec int) ([]int16, error) {
25+// decodeWindow decodes maxSec seconds of a file starting at offsetSec into
26+// mono s16le PCM.
27+func decodeWindow(path string, offsetSec, maxSec int) ([]int16, error) {
28 cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
29+ "-ss", fmt.Sprintf("%d", offsetSec),
30 "-i", path,
31 "-t", fmt.Sprintf("%d", maxSec),
32 "-ac", "1",
33@@ -40,8 +44,8 @@ func decodeIntro(path string, maxSec int) ([]int16, error) {
34 }
35
36 // findStart returns the sample offset where the RMS level first crosses the
37-// average RMS of the decoded window, so quiet intros are skipped. It scans
38-// in 10ms windows and falls back to 0 if the audio is silent throughout.
39+// average RMS of the decoded window, so silent stretches are skipped. It
40+// scans in 10ms windows and falls back to 0 if the audio is silent throughout.
41 func findStart(samples []int16) int {
42 win := sampleRate / 100 // 10ms
43
44@@ -76,20 +80,53 @@ func rms(s []int16) float64 {
45 return math.Sqrt(float64(sum) / float64(len(s)))
46 }
47
48-// playSegment decodes the intro of songPath, locates where the volume crosses
49-// the window average, and plays the following seconds via aplay.
50-func playSegment(songPath string, seconds int) error {
51- samples, err := decodeIntro(songPath, introWindow)
52+// duration returns the total length of path in seconds.
53+func duration(path string) (int, error) {
54+ cmd := exec.Command("ffprobe", "-v", "error",
55+ "-show_entries", "format=duration",
56+ "-of", "default=noprint_wrappers=1:nokey=1", path)
57+ var out bytes.Buffer
58+ cmd.Stdout = &out
59+ if err := cmd.Run(); err != nil {
60+ return 0, fmt.Errorf("ffprobe: %w", err)
61+ }
62+ var d float64
63+ if _, err := fmt.Sscanf(strings.TrimSpace(out.String()), "%f", &d); err != nil {
64+ return 0, fmt.Errorf("parse duration: %w", err)
65+ }
66+ return int(d), nil
67+}
68+
69+// pickStart chooses a random searchWindow-second window within the song and
70+// returns the offset (in seconds) of a point in it where the volume is above
71+// the window's average, so silent stretches are skipped.
72+func pickStart(songPath string) (int, error) {
73+ total, err := duration(songPath)
74 if err != nil {
75- return err
76+ return 0, err
77+ }
78+
79+ maxOffset := total - searchWindow
80+ if maxOffset < 0 {
81+ maxOffset = 0
82+ }
83+ offset := rand.Intn(maxOffset + 1)
84+
85+ samples, err := decodeWindow(songPath, offset, searchWindow)
86+ if err != nil {
87+ return 0, err
88 }
89 if len(samples) == 0 {
90- return fmt.Errorf("no audio decoded from %s", songPath)
91+ return 0, fmt.Errorf("no audio decoded from %s", songPath)
92 }
93
94 start := findStart(samples)
95- startSec := start / sampleRate
96+ return offset + start/sampleRate, nil
97+}
98
99+// playSegment encodes `seconds` of the song starting at startSec into a temp
100+// WAV and plays it via aplay.
101+func playSegment(songPath string, startSec, seconds int) error {
102 dir, err := os.MkdirTemp("", "museguessr-*")
103 if err != nil {
104 return err
105diff --git a/catalog.go b/catalog.go
106index 93ef5cbf81026fe37bad4040ddb384e4cd8e3e39..72806d9c9917ed2325c6999b84a8d72cc908ee3c 100644
107--- a/catalog.go
108+++ b/catalog.go
109@@ -27,28 +27,19 @@ var musicExts = map[string]bool{
110 ".wma": true, ".aiff": true, ".aif": true,
111 }
112
113-// catalogStats summarises how the catalog was assembled, so the caller can
114-// report how much came from the cache versus a fresh tag read.
115-type catalogStats struct {
116- Total int
117- FromCache int
118- Scanned int
119-}
120-
121 // buildCatalog walks dir recursively, keeps music files not under a
122 // blacklisted directory, and produces the song catalog. Tag reading is the
123 // slow part, so results are cached on disk keyed by file mtime: a fast walk
124 // (stat only) collects the current files, unchanged files reuse their cached
125-// tags, and only new or modified files are re-parsed. onProgress is called
126-// (if non-nil) with (count, total) for every file added to the catalog.
127-func buildCatalog(dir, blacklistPath string, onProgress func(count, total int)) ([]Song, catalogStats, error) {
128+// tags, and only new or modified files are re-parsed.
129+func buildCatalog(dir, blacklistPath string) ([]Song, error) {
130 absDir, err := filepath.Abs(dir)
131 if err != nil {
132- return nil, catalogStats{}, err
133+ return nil, err
134 }
135 blacklistDirs, err := loadBlacklistDirs(absDir, blacklistPath)
136 if err != nil {
137- return nil, catalogStats{}, err
138+ return nil, err
139 }
140
141 // Fast walk: collect current music files and their mtimes (no tag parsing).
142@@ -70,34 +61,26 @@ func buildCatalog(dir, blacklistPath string, onProgress func(count, total int))
143 current = append(current, fileRef{path, info.ModTime().UnixNano()})
144 return nil
145 }); walkErr != nil {
146- return nil, catalogStats{}, walkErr
147+ return nil, walkErr
148 }
149
150 cached := loadCache(cacheFilePath(absDir, blacklistPath))
151
152 songs := make([]Song, 0, len(current))
153 newCache := make(map[string]cacheEntry, len(current))
154- stats := catalogStats{Total: len(current)}
155- count := 0
156 for _, fr := range current {
157- count++
158- if onProgress != nil {
159- onProgress(count, len(current))
160- }
161 if e, ok := cached[fr.path]; ok && e.Mtime == fr.mtime {
162 songs = append(songs, Song{Path: fr.path, Title: e.Title, Artist: e.Artist, Album: e.Album})
163 newCache[fr.path] = e
164- stats.FromCache++
165 continue
166 }
167 s := readSong(fr.path)
168 songs = append(songs, s)
169 newCache[fr.path] = cacheEntry{Title: s.Title, Artist: s.Artist, Album: s.Album, Mtime: fr.mtime}
170- stats.Scanned++
171 }
172
173 saveCache(cacheFilePath(absDir, blacklistPath), newCache)
174- return songs, stats, nil
175+ return songs, nil
176 }
177
178 // loadBlacklistDirs reads a file with one directory per line. Blank lines and
179diff --git a/go.mod b/go.mod
180index dd43aa1048552740d35e64a154bd14819f6e4eb0..d47a994e65774cbb3d2df3fae06636cd1cdd160e 100644
181--- a/go.mod
182+++ b/go.mod
183@@ -31,5 +31,4 @@ require (
184 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
185 golang.org/x/sys v0.48.0 // indirect
186 golang.org/x/text v0.3.8 // indirect
187- golang.org/x/tools v0.51.0 // indirect
188 )
189diff --git a/go.sum b/go.sum
190index f50493751b9ea5206e76014c1a4c30db57b6b84f..b2f23a51f7a929d289ab635f4739546cffe05ed1 100644
191--- a/go.sum
192+++ b/go.sum
193@@ -48,11 +48,7 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
194 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
195 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
196 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
197-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
198-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
199 golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
200 golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
201 golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
202 golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
203-golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
204-golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
205diff --git a/main.go b/main.go
206index 0ef9e880184787594738cd45eb14b22cf0701358..46174d318846d1f677504639d5c05e10efe6448d 100644
207--- a/main.go
208+++ b/main.go
209@@ -14,15 +14,11 @@ func main() {
210 rounds := flag.Int("rounds", 10, "number of rounds")
211 flag.Parse()
212
213- fmt.Printf("Scanning %s …\n", *dir)
214- catalog, stats, err := buildCatalog(*dir, *blacklist, func(n, total int) {
215- fmt.Printf("\r %d/%d (%d%%)\x1b[K", n, total, n*100/total)
216- })
217+ catalog, err := buildCatalog(*dir, *blacklist)
218 if err != nil {
219- fmt.Fprintf(os.Stderr, "\nerror building catalog: %v\n", err)
220+ fmt.Fprintf(os.Stderr, "error building catalog: %v\n", err)
221 os.Exit(1)
222 }
223- fmt.Printf("\nFound %d songs (%d cached, %d new).\n", stats.Total, stats.FromCache, stats.Scanned)
224 if len(catalog) == 0 {
225 fmt.Fprintln(os.Stderr, "no songs found in the catalog")
226 os.Exit(1)
227diff --git a/ui.go b/ui.go
228index 3a9f98d8a680e0c1fa639063e7a99ef317ceb272..7a9cd717dad722c004a0407a23a027f670d28a87 100644
229--- a/ui.go
230+++ b/ui.go
231@@ -18,8 +18,16 @@ const (
232 stateResult
233 )
234
235-// audioDoneMsg is sent back once playback of the segment finishes.
236-type audioDoneMsg struct{}
237+const (
238+ defaultPlay = 2 // seconds of audio for a normal segment
239+ extendedPlay = 4 // seconds of audio for the extended hint
240+)
241+
242+// audioMsg is sent back once playback of the segment finishes, carrying the
243+// start offset (in seconds) that was played so replays can reuse it.
244+type audioMsg struct {
245+ startSec int
246+}
247
248 type model struct {
249 catalog []Song
250@@ -37,6 +45,7 @@ type model struct {
251 playing bool
252 hinted bool
253 answer Song
254+ startSec int
255 resultMsg string
256 done bool
257 }
258@@ -51,13 +60,14 @@ func newModel(catalog []Song, rounds int) model {
259 }
260
261 m := model{
262- catalog: catalog,
263- rounds: rounds,
264- inputs: inputs,
265- order: shuffled(len(catalog)),
266- st: statePlaying,
267- playing: true,
268- active: 0,
269+ catalog: catalog,
270+ rounds: rounds,
271+ inputs: inputs,
272+ order: balancedOrder(catalog),
273+ st: statePlaying,
274+ playing: true,
275+ active: 0,
276+ startSec: -1,
277 }
278 // Pre-select the first song so Init can return its playback command.
279 m.answer = catalog[m.order[0]]
280@@ -67,22 +77,31 @@ func newModel(catalog []Song, rounds int) model {
281 }
282
283 func (m model) Init() tea.Cmd {
284- return startAudio(m.answer, 1)
285+ return startAudio(m.answer, m.startSec, defaultPlay)
286 }
287
288-// startAudio is a command that plays the one-second segment of song and then
289-// reports back with audioDoneMsg. bubbletea runs it in a goroutine, so the TUI
290-// stays responsive while the audio plays.
291-func startAudio(song Song, seconds int) tea.Cmd {
292+// startAudio is a command that plays `seconds` of song starting at startSec
293+// and reports back with the offset it played. If startSec is negative, a random
294+// non-silent start is chosen first. bubbletea runs it in a goroutine, so the
295+// TUI stays responsive while the audio plays.
296+func startAudio(song Song, startSec, seconds int) tea.Cmd {
297 return func() tea.Msg {
298- _ = playSegment(song.Path, seconds)
299- return audioDoneMsg{}
300+ if startSec < 0 {
301+ s, err := pickStart(song.Path)
302+ if err != nil {
303+ s = 0
304+ }
305+ startSec = s
306+ }
307+ _ = playSegment(song.Path, startSec, seconds)
308+ return audioMsg{startSec: startSec}
309 }
310 }
311
312 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
313 switch msg := msg.(type) {
314- case audioDoneMsg:
315+ case audioMsg:
316+ m.startSec = msg.startSec
317 m.playing = false
318 if m.st == statePlaying {
319 m.st = stateGuess
320@@ -116,14 +135,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
321 return m, nil
322 }
323 m.playing = true
324- return m, startAudio(m.answer, 1)
325+ return m, startAudio(m.answer, m.startSec, defaultPlay)
326 case tea.KeyCtrlL:
327 if m.playing {
328 return m, nil
329 }
330 m.playing = true