73a317f045b9982148b05faed78cf86f696d82a2

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

Message

Simplify

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