Diff
1diff --git a/AGENTS.md b/AGENTS.md
2index 6d4c4b37c946f3cd507fe3899fba14cbed8ab755..3d62630a5a66e1c012d9d73565d054a7b2737c77 100644
3--- a/AGENTS.md
4+++ b/AGENTS.md
5@@ -1,9 +1,9 @@
6 # Muse Guessr
7
8 A TUI music guessing game in Go. It scans a music directory, plays short
9-intro segments of songs via `aplay`, and the player types the title, album,
10-and artist. Fuzzy matching scores the guess; a 2-second replay hint halves
11-the points for that round.
12+segments from a random position in each song via `aplay`, and scores the
13+player's title/album/artist guesses with fuzzy matching. Game rules and
14+controls are documented in README.md.
15
16 ## Project structure
17
18@@ -14,26 +14,25 @@ Single `main` package, one file per concern:
19 - `catalog.go` — recursive music-file walk, blacklist filtering, tag reading
20 (via `github.com/dhowden/tag`), and an on-disk tag cache keyed by file
21 mtime so rescans are fast. Cache lives in `os.UserCacheDir()/museguessr/`.
22-- `audio.go` — ffmpeg decodes the first `introWindow` (30s) of a file to
23- mono 44.1kHz s16le; `findStart` locates where RMS crosses `-threshold`
24- in 10ms windows, falling back to 10% of the window's peak RMS if the
25- threshold is never reached (handles long quiet intros); the segment is
26- written as a temp WAV and played with `aplay`. Requires ffmpeg and aplay
27- on PATH.
28+- `audio.go` — `pickStart` chooses a random 30s (`searchWindow`) window in
29+ the file; ffmpeg decodes it to mono 44.1kHz s16le and `findStart` returns
30+ the first 10ms window whose RMS meets the window's average RMS (fallback:
31+ the window start); the segment is written as a temp WAV and played with
32+ `aplay`. Requires ffmpeg, ffprobe, and aplay on PATH.
33 - `matching.go` — `fuzzyMatch`: normalized similarity (title 4 pts, album 4 pts,
34 artist 2 pts) with a parenthesized-suffix retry; `matchThreshold = 0.7`.
35 - `ui.go` — bubbletea TUI: model, game states (playing/guess/result),
36- keyboard handling (Tab, Enter, Esc, Ctrl+R replay, Ctrl+L 2s hint),
37- scoring, and the `startAudio` tea.Cmd that runs playback in a goroutine.
38+ keyboard handling, scoring, and the `startAudio` tea.Cmd that runs
39+ playback in a goroutine.
40 - `blacklist` — local blacklist file (one directory per line).
41
42 ## Conventions
43
44 - Do NOT add new CLI flags, config options, or user-facing knobs unless
45- explicitly asked. Hardcode reasonable constants (e.g. `introWindow`)
46+ explicitly asked. Hardcode reasonable constants (e.g. `searchWindow`)
47 instead.
48 - Do NOT write any tests unless explicitly asked to.
49 - Keep the single-package layout; don't split into subpackages.
50-- External dependencies are ffmpeg and aplay — shell out to them rather
51- than adding Go audio libraries.
52+- External dependencies are ffmpeg, ffprobe, and aplay — shell out to them
53+ rather than adding Go audio libraries.
54 - Check with `golangci-lint ./...` after changes.
55diff --git a/README.md b/README.md
56index 63a80c0bcd42ac2e6afa8ad6e0a9803bcfe58f8e..ac062bdc50b67ef4c5b742c26570fbd90f6a60e7 100644
57--- a/README.md
58+++ b/README.md
59@@ -2,8 +2,8 @@
60
61 A simple CLI music guessing game built with Go and [bubbletea](https://github.com/charmbracelet/bubbletea).
62
63-It scans a directory of music files, plays a one-second clip from each song, and
64-you guess the title, album, and artist.
65+It scans a directory of music files, plays a short 2-second segment from a
66+random position in each song, and you guess the title, album, and artist.
67
68 ## How it works
69
70@@ -11,16 +11,17 @@ you guess the title, album, and artist.
71 types, skips files under any blacklisted directory, and reads each file's title/album/artist
72 tags (via [`dhowden/tag`](https://github.com/dhowden/tag)). Files without
73 readable tags fall back to the filename as the title.
74-2. **Clip** — decodes the first 30 seconds of a song to mono PCM with `ffmpeg`,
75- finds the point where the RMS volume first crosses the average volume of
76- that window (so quiet, slow intros are skipped), and plays the following
77- one second via `aplay`.
78+2. **Clip** — picks a random 30-second window in the song (using `ffprobe`
79+ for the duration), decodes it to mono PCM with `ffmpeg`, finds the first
80+ point where the RMS volume meets the window's average volume (so silent
81+ stretches are skipped), and plays the following 2 seconds via `aplay`.
82 3. **Guess** — the TUI shows three input fields (title, album, artist). You type your
83 answer and submit. Matching ignores casing, punctuation, and extra whitespace,
84 allows typos, and accepts omitted parenthesized extras such as `(Live)`.
85 The similarity threshold is `matchThreshold = 0.7` in `matching.go`.
86 4. **Score** — earn 4 points for the title, 4 for the album, and 2 for the artist,
87- independently. Each round is worth up to 10 points. Blank answers earn no points.
88+ independently. Each round is worth up to 10 points. Blank answers earn no
89+ points. Using the extended hint (Ctrl+L) halves the score for that round.
90
91 Songs are played in a shuffled order.
92
93@@ -28,6 +29,7 @@ Songs are played in a shuffled order.
94
95 - Go (to build)
96 - `ffmpeg` on `PATH` (decoding)
97+- `ffprobe` on `PATH` (duration lookup)
98 - `aplay` on `PATH` (playback, from ALSA)
99
100 ## Build
101@@ -42,11 +44,11 @@ go build -o museguessr .
102 museguessr -dir /path/to/music [-blacklist blacklist.txt] [-rounds 10]
103 ```
104
105-| Flag | Default | Description |
106-| ------------ | ------------ | ------------------------------------------------------------------ |
107-| `-dir` | `/mnt/media/music` | Directory to scan for music files (searched recursively). |
108-| `-blacklist` | `blacklist` | File with one directory per line; every file under it is excluded. |
109-| `-rounds` | catalog size | Number of songs to play. `0` means one per song in the catalog. |
110+| Flag | Default | Description |
111+| ------------ | ------------------ | ------------------------------------------------------------------ |
112+| `-dir` | `/mnt/media/music` | Directory to scan for music files (searched recursively). |
113+| `-blacklist` | `blacklist` | File with one directory per line; every file under it is excluded. |
114+| `-rounds` | `10` | Number of songs to play. `0` means one per song in the catalog. |
115
116 ### Blacklist file
117
118@@ -62,8 +64,8 @@ Remixes
119
120 ### Controls (in the TUI)
121
122-- `Tab` — switch between the title, album, and artist fields
123-- `Ctrl+R` — replay the 1-second clip
124-- `Ctrl+L` — play a longer 2-second clip
125-- `Enter` — submit your guess / advance to the next song
126+- `Tab` / `Shift+Tab` — move between input fields
127+- `Ctrl+R` — replay the music segment
128+- `Ctrl+L` — play a longer 4-second hint (halves the score for that round)
129+- `Enter` (or `Space`) — submit your guess / advance to the next song
130 - `Esc` — skip ahead while a clip is playing, or quit on the final screen
131diff --git a/audio.go b/audio.go
132index 66578dfae21e668c259ec50a69b48121645eda3d..1c7858fd02f0c030669f8a44415198eab99b8d99 100644
133--- a/audio.go
134+++ b/audio.go
135@@ -9,6 +9,7 @@ import (
136 "os"
137 "os/exec"
138 "path/filepath"
139+ "strconv"
140 "strings"
141 )
142
143@@ -21,11 +22,11 @@ const (
144 // mono s16le PCM.
145 func decodeWindow(path string, offsetSec, maxSec int) ([]int16, error) {
146 cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
147- "-ss", fmt.Sprintf("%d", offsetSec),
148+ "-ss", strconv.Itoa(offsetSec),
149 "-i", path,
150- "-t", fmt.Sprintf("%d", maxSec),
151+ "-t", strconv.Itoa(maxSec),
152 "-ac", "1",
153- "-ar", fmt.Sprintf("%d", sampleRate),
154+ "-ar", strconv.Itoa(sampleRate),
155 "-f", "s16le", "-")
156
157 var out bytes.Buffer
158@@ -135,8 +136,8 @@ func playSegment(songPath string, startSec, seconds int) error {
159
160 wavPath := filepath.Join(dir, "segment.wav")
161 cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
162- "-ss", fmt.Sprintf("%d", startSec), "-t", fmt.Sprintf("%d", seconds),
163- "-i", songPath, "-ac", "1", "-ar", fmt.Sprintf("%d", sampleRate), wavPath)
164+ "-ss", strconv.Itoa(startSec), "-t", strconv.Itoa(seconds),
165+ "-i", songPath, "-ac", "1", "-ar", strconv.Itoa(sampleRate), wavPath)
166 if err := cmd.Run(); err != nil {
167 return fmt.Errorf("ffmpeg encode: %w", err)
168 }
169diff --git a/catalog.go b/catalog.go
170index 72806d9c9917ed2325c6999b84a8d72cc908ee3c..f6ca5c1d7e2164cda131254aed004a68d927edfc 100644
171--- a/catalog.go
172+++ b/catalog.go
173@@ -64,7 +64,8 @@ func buildCatalog(dir, blacklistPath string) ([]Song, error) {
174 return nil, walkErr
175 }
176
177- cached := loadCache(cacheFilePath(absDir, blacklistPath))
178+ cachePath := cacheFilePath(absDir, blacklistPath)
179+ cached := loadCache(cachePath)
180
181 songs := make([]Song, 0, len(current))
182 newCache := make(map[string]cacheEntry, len(current))
183@@ -79,7 +80,7 @@ func buildCatalog(dir, blacklistPath string) ([]Song, error) {
184 newCache[fr.path] = cacheEntry{Title: s.Title, Artist: s.Artist, Album: s.Album, Mtime: fr.mtime}
185 }
186
187- saveCache(cacheFilePath(absDir, blacklistPath), newCache)
188+ saveCache(cachePath, newCache)
189 return songs, nil
190 }
191
192diff --git a/ui.go b/ui.go
193index 413189a94c9a6022be68af4ec80659ac4f834731..9cfce174e4b801746fd1274ad9d2718d77681199 100644
194--- a/ui.go
195+++ b/ui.go
196@@ -52,13 +52,23 @@ type model struct {
197 done bool
198 }
199
200-var fieldPrompts = []string{"Title: ", "Album: ", "Artist: "}
201+type fieldSpec struct {
202+ label string
203+ weight int
204+ value func(Song) string
205+}
206+
207+var fields = []fieldSpec{
208+ {"Title", pointsTitle, func(s Song) string { return s.Title }},
209+ {"Album", pointsAlbum, func(s Song) string { return s.Album }},
210+ {"Artist", pointsArtist, func(s Song) string { return s.Artist }},
211+}
212
213 func newModel(catalog []Song, rounds int) model {
214- inputs := make([]textinput.Model, len(fieldPrompts))
215- for i, p := range fieldPrompts {
216+ inputs := make([]textinput.Model, len(fields))
217+ for i, f := range fields {
218 inputs[i] = textinput.New()
219- inputs[i].Prompt = p
220+ inputs[i].Prompt = f.label + ": "
221 }
222
223 m := model{
224@@ -118,10 +128,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
225 case statePlaying:
226 if msg.Type == tea.KeyEsc {
227 m.st = stateGuess
228- for i := range m.inputs {
229- m.inputs[i].Blur()
230- }
231- m.inputs[0].Focus()
232+ m.moveField(0)
233 return m, nil
234 }
235 case stateGuess:
236@@ -180,7 +187,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
237 }
238
239 // beginRound starts a new round for song: records it as the answer, clears
240-// the input fields, and kicks off playback of its one-second segment.
241+// the input fields, and kicks off playback of its segment.
242 func (m *model) beginRound(song Song) tea.Cmd {
243 m.answer = song
244 m.st = statePlaying
245@@ -215,12 +222,7 @@ func (m *model) nextSong() Song {
246 }
247
248 // submitGuess compares the entered title, artist and album against the answer.
249-var fieldWeights = []int{pointsTitle, pointsAlbum, pointsArtist}
250-var fieldLabels = []string{"Title", "Album", "Artist"}
251-
252 func (m *model) submitGuess() tea.Cmd {
253- answers := []string{m.answer.Title, m.answer.Album, m.answer.Artist}
254-
255 points := 0
256 var parts []string
257 m.guesses = make([]string, len(m.inputs))
258@@ -228,10 +230,10 @@ func (m *model) submitGuess() tea.Cmd {
259 for i, input := range m.inputs {
260 v := input.Value()
261 m.guesses[i] = v
262- if v != "" && fuzzyMatch(v, answers[i]) {
263- points += fieldWeights[i]
264+ if v != "" && fuzzyMatch(v, fields[i].value(m.answer)) {
265+ points += fields[i].weight
266 m.fieldCorrect[i] = true
267- parts = append(parts, fieldLabels[i])
268+ parts = append(parts, fields[i].label)
269 }
270 }
271 if m.hinted {
272@@ -306,11 +308,12 @@ func (m model) View() string {
273 b.WriteString(style.Render(mark + m.resultMsg))
274 if !m.done {
275 b.WriteString("\n\n")
276- for i, label := range fieldPrompts {
277+ for i, f := range fields {
278 if i > 0 {
279 b.WriteString("\n")
280 }
281- answer := []string{m.answer.Title, m.answer.Album, m.answer.Artist}[i]
282+ label := f.label + ": "
283+ answer := f.value(m.answer)
284 switch {
285 case m.fieldCorrect[i]:
286 b.WriteString(dim.Render(label) + green.Render(answer))