351997d2b5dd27e0635c3106d2b55bf9e52f868d

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Play random part of the song

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