b62e22083f7260c1ce5ddfb7e80dcf774c55b26a
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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-}