b62e22083f7260c1ce5ddfb7e80dcf774c55b26a

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

Message

Simplifications

Diff

This diff is truncated to protect this page.

  1diff --git a/AGENTS.md b/AGENTS.md
  2index 01610213db82d62d58acfa58eb61fda80c260095..6d4c4b37c946f3cd507fe3899fba14cbed8ab755 100644
  3--- a/AGENTS.md
  4+++ b/AGENTS.md
  5@@ -1,15 +1,15 @@
  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 and
 10-artist. Fuzzy matching scores the guess; a 2-second replay hint halves the
 11-points for that round.
 12+intro segments of songs via `aplay`, and the player types the title, album,
 13+and artist. Fuzzy matching scores the guess; a 2-second replay hint halves
 14+the points for that round.
 15 
 16 ## Project structure
 17 
 18 Single `main` package, one file per concern:
 19 
 20-- `main.go` — CLI flags (`-dir`, `-blacklist`, `-threshold`, `-rounds`),
 21+- `main.go` — CLI flags (`-dir`, `-blacklist`, `-rounds`),
 22   catalog build, TUI startup.
 23 - `catalog.go` — recursive music-file walk, blacklist filtering, tag reading
 24   (via `github.com/dhowden/tag`), and an on-disk tag cache keyed by file
 25@@ -20,12 +20,11 @@ Single `main` package, one file per concern:
 26   threshold is never reached (handles long quiet intros); the segment is
 27   written as a temp WAV and played with `aplay`. Requires ffmpeg and aplay
 28   on PATH.
 29-- `matching.go` — `fuzzyMatch`: normalized similarity (title 4 pts, artist
 30-  2 pts) with a parenthesized-suffix retry; `matchThreshold = 0.8`.
 31+- `matching.go` — `fuzzyMatch`: normalized similarity (title 4 pts, album 4 pts,
 32+  artist 2 pts) with a parenthesized-suffix retry; `matchThreshold = 0.7`.
 33 - `ui.go` — bubbletea TUI: model, game states (playing/guess/result),
 34   keyboard handling (Tab, Enter, Esc, Ctrl+R replay, Ctrl+L 2s hint),
 35   scoring, and the `startAudio` tea.Cmd that runs playback in a goroutine.
 36-- `ui_test.go`, `matching_test.go` — unit tests for game flow and matching.
 37 - `blacklist` — local blacklist file (one directory per line).
 38 
 39 ## Conventions
 40@@ -33,7 +32,8 @@ Single `main` package, one file per concern:
 41 - Do NOT add new CLI flags, config options, or user-facing knobs unless
 42   explicitly asked. Hardcode reasonable constants (e.g. `introWindow`)
 43   instead.
 44+- Do NOT write any tests unless explicitly asked to.
 45 - Keep the single-package layout; don't split into subpackages.
 46 - External dependencies are ffmpeg and aplay — shell out to them rather
 47   than adding Go audio libraries.
 48-- Check with `golangci-lint ./...` and run `go test ./...` after changes.
 49+- Check with `golangci-lint ./...` after changes.
 50diff --git a/README.md b/README.md
 51index a31d748ea38a412a570e7f8efde072d2dc51f104..63a80c0bcd42ac2e6afa8ad6e0a9803bcfe58f8e 100644
 52--- a/README.md
 53+++ b/README.md
 54@@ -3,24 +3,24 @@
 55 A simple CLI music guessing game built with Go and [bubbletea](https://github.com/charmbracelet/bubbletea).
 56 
 57 It scans a directory of music files, plays a one-second clip from each song, and
 58-you guess the title and artist.
 59+you guess the title, album, and artist.
 60 
 61 ## How it works
 62 
 63 1. **Catalog** — walks the given directory recursively, keeps common music file
 64-   types, skips files under any blacklisted directory, and reads each file's title/artist
 65+   types, skips files under any blacklisted directory, and reads each file's title/album/artist
 66    tags (via [`dhowden/tag`](https://github.com/dhowden/tag)). Files without
 67    readable tags fall back to the filename as the title.
 68 2. **Clip** — decodes the first 30 seconds of a song to mono PCM with `ffmpeg`,
 69    finds the point where the RMS volume first crosses the average volume of
 70    that window (so quiet, slow intros are skipped), and plays the following
 71    one second via `aplay`.
 72-3. **Guess** — the TUI shows two input fields (title, artist). You type your
 73+3. **Guess** — the TUI shows three input fields (title, album, artist). You type your
 74    answer and submit. Matching ignores casing, punctuation, and extra whitespace,
 75    allows typos, and accepts omitted parenthesized extras such as `(Live)`.
 76-   The similarity threshold is `matchThreshold = 0.8` in `matching.go`.
 77-4. **Score** — earn 1 point for the title and 1 for the artist, independently.
 78-   Each round is worth up to 2 points. Blank answers earn no points.
 79+   The similarity threshold is `matchThreshold = 0.7` in `matching.go`.
 80+4. **Score** — earn 4 points for the title, 4 for the album, and 2 for the artist,
 81+   independently. Each round is worth up to 10 points. Blank answers earn no points.
 82 
 83 Songs are played in a shuffled order.
 84 
 85@@ -44,7 +44,7 @@ museguessr -dir /path/to/music [-blacklist blacklist.txt] [-rounds 10]
 86 
 87 | Flag         | Default      | Description                                                        |
 88 | ------------ | ------------ | ------------------------------------------------------------------ |
 89-| `-dir`       | `.`          | Directory to scan for music files (searched recursively).          |
 90+| `-dir`       | `/mnt/media/music` | Directory to scan for music files (searched recursively).    |
 91 | `-blacklist` | `blacklist`  | File with one directory per line; every file under it is excluded. |
 92 | `-rounds`    | catalog size | Number of songs to play. `0` means one per song in the catalog.    |
 93 
 94@@ -62,7 +62,7 @@ Remixes
 95 
 96 ### Controls (in the TUI)
 97 
 98-- `Tab` — switch between the title and artist fields
 99+- `Tab` — switch between the title, album, and artist fields
100 - `Ctrl+R` — replay the 1-second clip
101 - `Ctrl+L` — play a longer 2-second clip
102 - `Enter` — submit your guess / advance to the next song
103diff --git a/audio.go b/audio.go
104index a60705ae2ff803b4289f4c3d8ca345483c859ba7..6b77026feadab482080c3bc67f5b5a8a7736fdd4 100644
105--- a/audio.go
106+++ b/audio.go
107@@ -44,9 +44,6 @@ func decodeIntro(path string, maxSec int) ([]int16, error) {
108 // in 10ms windows and falls back to 0 if the audio is silent throughout.
109 func findStart(samples []int16) int {
110 	win := sampleRate / 100 // 10ms
111-	if win < 1 {
112-		win = 1
113-	}
114 
115 	rmsVals := make([]float64, 0, len(samples)/win)
116 	var sum float64
117@@ -91,13 +88,7 @@ func playSegment(songPath string, seconds int) error {
118 	}
119 
120 	start := findStart(samples)
121-	end := start + seconds*sampleRate
122-	if end > len(samples) {
123-		end = len(samples)
124-	}
125-	if end <= start {
126-		return fmt.Errorf("no audio to play for %s", songPath)
127-	}
128+	startSec := start / sampleRate
129 
130 	dir, err := os.MkdirTemp("", "museguessr-*")
131 	if err != nil {
132@@ -106,51 +97,17 @@ func playSegment(songPath string, seconds int) error {
133 	defer func() { _ = os.RemoveAll(dir) }()
134 
135 	wavPath := filepath.Join(dir, "segment.wav")
136-	if err := writeWAV(wavPath, samples[start:end]); err != nil {
137-		return err
138+	cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
139+		"-ss", fmt.Sprintf("%d", startSec), "-t", fmt.Sprintf("%d", seconds),
140+		"-i", songPath, "-ac", "1", "-ar", fmt.Sprintf("%d", sampleRate), wavPath)
141+	if err := cmd.Run(); err != nil {
142+		return fmt.Errorf("ffmpeg encode: %w", err)
143 	}
144 
145-	cmd := exec.Command("aplay", "-q", wavPath)
146+	cmd = exec.Command("aplay", "-q", wavPath)
147 	cmd.Stdout = os.Stderr
148 	cmd.Stderr = os.Stderr
149 	return cmd.Run()
150 }
151 
152-// writeWAV writes samples as a 16-bit mono PCM WAV file.
153-func writeWAV(path string, samples []int16) error {
154-	dataSize := len(samples) * 2
155-	buf := bytes.NewBuffer(make([]byte, 0, 44+dataSize))
156 
157-	var u32 [4]byte
158-	var u16 [2]byte
159-	le := binary.LittleEndian
160-
161-	buf.WriteString("RIFF")
162-	le.PutUint32(u32[:], uint32(36+dataSize))
163-	buf.Write(u32[:])
164-	buf.WriteString("WAVEfmt ")
165-	le.PutUint32(u32[:], 16)
166-	buf.Write(u32[:])
167-	le.PutUint16(u16[:], 1) // PCM
168-	buf.Write(u16[:])
169-	le.PutUint16(u16[:], 1) // channels
170-	buf.Write(u16[:])
171-	le.PutUint32(u32[:], uint32(sampleRate))
172-	buf.Write(u32[:])
173-	le.PutUint32(u32[:], uint32(sampleRate*2)) // byte rate
174-	buf.Write(u32[:])
175-	le.PutUint16(u16[:], 2) // block align
176-	buf.Write(u16[:])
177-	le.PutUint16(u16[:], 16) // bits per sample
178-	buf.Write(u16[:])
179-	buf.WriteString("data")
180-	le.PutUint32(u32[:], uint32(dataSize))
181-	buf.Write(u32[:])
182-
183-	for _, s := range samples {
184-		le.PutUint16(u16[:], uint16(s))
185-		buf.Write(u16[:])
186-	}
187-
188-	return os.WriteFile(path, buf.Bytes(), 0o644)
189-}
190diff --git a/catalog.go b/catalog.go
191index 1314e1f73d11548bc52bb112458f8647fd455ba7..0e1f6920118376d576a0cc66062a13456a42f783 100644
192--- a/catalog.go
193+++ b/catalog.go
194@@ -27,8 +27,6 @@ var musicExts = map[string]bool{
195 	".wma": true, ".aiff": true, ".aif": true,
196 }
197 
198-// buildCatalog walks dir recursively, keeps music files not on the blacklist,
199-// and reads their title/artist tags.
200 // catalogStats summarises how the catalog was assembled, so the caller can
201 // report how much came from the cache versus a fresh tag read.
202 type catalogStats struct {
203@@ -98,7 +96,7 @@ func buildCatalog(dir, blacklistPath string, onProgress func(count, total int))
204 		stats.Scanned++
205 	}
206 
207-	saveCache(cacheFilePath(absDir, blacklistPath), absDir, blacklistPath, newCache)
208+	saveCache(cacheFilePath(absDir, blacklistPath), newCache)
209 	return songs, stats, nil
210 }
211 
212@@ -173,7 +171,6 @@ type cacheEntry struct {
213 
214 // cacheFile is the on-disk catalog cache.
215 type cacheFile struct {
216-	Key   string                `json:"key"`
217 	Files map[string]cacheEntry `json:"files"`
218 }
219 
220@@ -212,8 +209,8 @@ func loadCache(path string) map[string]cacheEntry {
221 
222 // saveCache writes the cache file. Failures are ignored: a cache write error
223 // should never break the game.
224-func saveCache(path, absDir, blacklistPath string, files map[string]cacheEntry) {
225-	cf := cacheFile{Key: cacheKey(absDir, blacklistPath), Files: files}
226+func saveCache(path string, files map[string]cacheEntry) {
227+	cf := cacheFile{Files: files}
228 	data, err := json.MarshalIndent(cf, "", "  ")
229 	if err != nil {
230 		return
231diff --git a/matching.go b/matching.go
232index f9d619044f641f51789ddf5e4cc2f9ce16837c2d..333783245f99c6f6f53920f4b3a5c54eaa3ce5c5 100644
233--- a/matching.go
234+++ b/matching.go
235@@ -5,8 +5,9 @@ import (
236 	"unicode"
237 )
238 
239-const matchThreshold = 0.7
240 const (
241+	matchThreshold = 0.7
242+
243 	pointsTitle    = 4
244 	pointsArtist   = 2
245 	pointsAlbum    = 4
246diff --git a/matching_test.go b/matching_test.go
247deleted file mode 100644
248index 7d9ed3ad3043262f7f070b2718bb70c561684eca..0000000000000000000000000000000000000000
249--- a/matching_test.go
250+++ /dev/null
251@@ -1,61 +0,0 @@
252-package main
253-
254-import "testing"
255-
256-func TestFuzzyMatch(t *testing.T) {
257-	for _, tt := range []struct {
258-		guess, answer string
259-		want          bool
260-	}{
261-		{"HELLO", "Hello", true},
262-		{"Bohemain Rhapsody", "Bohemian Rhapsody", true},
263-		{"Song", "Song (Live at Wembley)", true},
264-		{"Song Live at Wembley", "Song (Live at Wembley)", true},
265-		{"Song (Live)", "Song", true},
266-		{"Beyoncè", "Beyoncé", true},
267-		{"hello", "hallo", true},
268-		{"Getaway", "The Getaway", true},
269-		{"hello", "hxllo!", true},
270-		{"hello", "hxxlo", false},
271-		{"unrelated", "Bohemian Rhapsody", false},
272-		{"", "", false},
273-		{"", "Artist", false},
274-		{"(Live)", "Song (Live)", false},
275-		{"Song", "Songbird", false},
276-	} {
277-		t.Run(tt.guess+"/"+tt.answer, func(t *testing.T) {
278-			if got := fuzzyMatch(tt.guess, tt.answer); got != tt.want {
279-				t.Fatalf("fuzzyMatch(%q, %q) = %v, want %v", tt.guess, tt.answer, got, tt.want)
280-			}
281-		})
282-	}
283-}
284-
285-func TestGuessScoring(t *testing.T) {
286-	for _, tt := range []struct {
287-		title, artist, album string
288-		hinted               bool
289-		points               int
290-		message              string
291-	}{
292-		{"Hello", "Adele", "21", false, 10, "Correct! +10 points"},
293-		{"Wrong", "Adele", "21", false, 6, "Artist, Album correct! +6 points"},
294-		{"Hello", "Wrong", "21", false, 8, "Title, Album correct! +8 points"},
295-		{"Hello", "Adele", "Wrong", false, 6, "Title, Artist correct! +6 points"},
296-		{"Wrong", "Wrong", "Wrong", false, 0, "Wrong."},
297-		{"", "", "", false, 0, "Wrong."},
298-		{"Hello", "Adele", "21", true, 5, "Correct! +5 points (halved for 2s hint)"},
299-		{"Hello", "Wrong", "21", true, 4, "Title, Album correct! +4 points (halved for 2s hint)"},
300-		{"Wrong", "Adele", "21", true, 3, "Artist, Album correct! +3 points (halved for 2s hint)"},
301-	} {
302-		m := newModel([]Song{{Title: "Hello", Artist: "Adele", Album: "21"}}, 1)
303-		m.titleInput.SetValue(tt.title)
304-		m.artistInput.SetValue(tt.artist)
305-		m.albumInput.SetValue(tt.album)
306-		m.hinted = tt.hinted
307-		m.submitGuess()
308-		if m.score != tt.points || m.resultMsg != tt.message || m.st != stateResult {
309-			t.Fatalf("guess %q / %q / %q (hinted=%v): score=%d, message=%q, state=%v", tt.title, tt.artist, tt.album, tt.hinted, m.score, m.resultMsg, m.st)
310-		}
311-	}
312-}
313diff --git a/ui.go b/ui.go
314index 0c9f79a9e3fb5acf3a30b63dccdebc968b7dd8f2..5289587a6ea12be81a7e14dfb1cfad558514ce5d 100644
315--- a/ui.go
316+++ b/ui.go
317@@ -28,12 +28,10 @@ type model struct {
318 	rounds    int
319 	played    int
320 	score     int
321-	correct   int
322+	resultParts []string
323 
324-	titleInput  textinput.Model
325-	artistInput textinput.Model
326-	albumInput  textinput.Model
327-	active      int // 0 = title, 1 = album, 2 = artist
328+	inputs []textinput.Model
329+	active int
330 
331 	st        state
332 	playing   bool
333@@ -43,30 +41,28 @@ type model struct {
334 	done      bool
335 }
336 
337+var fieldPrompts = []string{"Title:  ", "Album:  ", "Artist: "}
338+
339 func newModel(catalog []Song, rounds int) model {
340-	ti := textinput.New()
341-	ti.Prompt = "Title:  "
342-	tt := textinput.New()
343-	tt.Prompt = "Artist: "
344-	ta := textinput.New()
345-	ta.Prompt = "Album:  "
346+	inputs := make([]textinput.Model, len(fieldPrompts))
347+	for i, p := range fieldPrompts {
348+		inputs[i] = textinput.New()
349+		inputs[i].Prompt = p
350+	}
351 
352 	m := model{
353-		catalog:     catalog,
354-		rounds:      rounds,
355-		titleInput:  ti,
356-		artistInput: tt,
357-		albumInput:  ta,
358-		order:       shuffled(len(catalog)),
359-		st:          statePlaying,
360-		playing:     true,
361-		active:      0,
362+		catalog: catalog,
363+		rounds:  rounds,
364+		inputs:  inputs,
365+		order:   shuffled(len(catalog)),
366+		st:      statePlaying,
367+		playing: true,
368+		active:  0,
369 	}
370 	// Pre-select the first song so Init can return its playback command.
371 	m.answer = catalog[m.order[0]]
372 	m.pos = 1
373-	m.titleInput.Focus()
374-	m.artistInput.Blur()
375+	m.inputs[0].Focus()
376 	return m
377 }
378 
379@@ -101,8 +97,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
380 		case statePlaying:
381 			if msg.Type == tea.KeyEsc {
382 				m.st = stateGuess
383-				m.titleInput.Focus()
384-				m.artistInput.Blur()
385+				for i := range m.inputs {
386+					m.inputs[i].Blur()
387+				}
388+				m.inputs[0].Focus()
389 				return m, nil
390 			}
391 		case stateGuess:
392@@ -129,14 +127,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
393 				}
394 				return m, m.submitGuess()
395 			default:
396-				switch m.active {
397-				case 0:
398-					m.titleInput, _ = m.titleInput.Update(msg)
399-				case 1:
400-					m.albumInput, _ = m.albumInput.Update(msg)
401-				default:
402-					m.artistInput, _ = m.artistInput.Update(msg)
403-				}
404+				m.inputs[m.active], _ = m.inputs[m.active].Update(msg)
405 				return m, nil
406 			}
407 		case stateResult:
408@@ -171,29 +162,20 @@ func (m *model) beginRound(song Song) tea.Cmd {
409 	m.st = statePlaying
410 	m.playing = true
411 	m.hinted = false
412-	m.titleInput.SetValue("")
413-	m.artistInput.SetValue("")
414-	m.albumInput.SetValue("")
415+	for i := range m.inputs {
416+		m.inputs[i].SetValue("")
417diff --git a/ui_test.go b/ui_test.go
418deleted file mode 100644
419index 93aa6092c3c7703a5172c15cdd853806ecbc6203..0000000000000000000000000000000000000000
420--- a/ui_test.go
421+++ /dev/null
422@@ -1,57 +0,0 @@
423-package main
424-
425-import (
426-	"testing"
427-
428-	tea "github.com/charmbracelet/bubbletea"
429-)
430-
431-func TestReplay(t *testing.T) {
432-	m := newModel([]Song{{Title: "Song", Artist: "Artist", Path: "song.mp3"}}, 1)
433-	updated, _ := m.Update(audioDoneMsg{})
434-	m = updated.(model)
435-	m.titleInput.SetValue("my title")
436-	m.artistInput.SetValue("my artist")
437-	updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
438-	m = updated.(model)
439-	if cmd == nil || !m.playing || m.st != stateGuess {
440-		t.Fatal("replay should start playback in the guessing screen")
441-	}
442-	_, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
443-	if cmd != nil {
444-		t.Fatal("replay should not start overlapping playback")
445-	}
446-	updated, _ = m.Update(audioDoneMsg{})
447-	m = updated.(model)
448-	if m.playing || m.active != 0 || !m.titleInput.Focused() || m.titleInput.Value() != "my title" || m.artistInput.Value() != "my artist" || m.score != 0 || m.played != 0 || m.pos != 1 {
449-		t.Fatal("replay should preserve guesses, focus, and round progress")
450-	}
451-	updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
452-	m = updated.(model)
453-	if cmd == nil || !m.playing {
454-		t.Fatal("Ctrl+L should start playback")
455-	}
456-	updated, _ = m.Update(audioDoneMsg{})
457-	m = updated.(model)
458-	if m.playing || m.titleInput.Value() != "my title" || m.artistInput.Value() != "my artist" {
459-		t.Fatal("longer replay should preserve guesses")
460-	}
461-
462-	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
463-	m = updated.(model)
464-	if m.active != 1 || !m.albumInput.Focused() {
465-		t.Fatal("Tab should switch to the album field")
466-	}
467-
468-	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
469-	m = updated.(model)
470-	if m.active != 2 || !m.artistInput.Focused() {
471-		t.Fatal("second Tab should switch to the artist field")
472-	}
473-
474-	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
475-	m = updated.(model)
476-	if m.active != 0 || !m.titleInput.Focused() {
477-		t.Fatal("third Tab should wrap back to the title field")
478-	}
479-}