4702d55a39521a792c0bc0d9d607e1e2bb6064a7
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/.gitignore b/.gitignore
2new file mode 100644
3index 0000000000000000000000000000000000000000..4458fbc12abfd78d06e4c7612a35ac5d6f6bb646
4--- /dev/null
5+++ b/.gitignore
6@@ -0,0 +1,2 @@
7+museguessr
8+blacklist
9diff --git a/.pi/settings.json b/.pi/settings.json
10new file mode 100644
11index 0000000000000000000000000000000000000000..df13b2b9638e3fae3588a69c357d60b49f8b9925
12--- /dev/null
13+++ b/.pi/settings.json
14@@ -0,0 +1,5 @@
15+{
16+ "defaultProvider": "llama.cpp",
17+ "defaultModel": "ukisai/Swift-1.5-Qwen3.8-27B-GGUF:Q3_K_S",
18+ "defaultThinkingLevel": "low"
19+}
20diff --git a/AGENTS.md b/AGENTS.md
21new file mode 100644
22index 0000000000000000000000000000000000000000..1cf7d32c6ab507366277ddcbcf3f324ee0f635e1
23--- /dev/null
24+++ b/AGENTS.md
25@@ -0,0 +1,39 @@
26+# Muse Guessr
27+
28+A TUI music guessing game in Go. It scans a music directory, plays short
29+intro segments of songs via `aplay`, and the player types the title and
30+artist. Fuzzy matching scores the guess; a 2-second replay hint halves the
31+points for that round.
32+
33+## Project structure
34+
35+Single `main` package, one file per concern:
36+
37+- `main.go` — CLI flags (`-dir`, `-blacklist`, `-threshold`, `-rounds`),
38+ catalog build, TUI startup.
39+- `catalog.go` — recursive music-file walk, blacklist filtering, tag reading
40+ (via `github.com/dhowden/tag`), and an on-disk tag cache keyed by file
41+ mtime so rescans are fast. Cache lives in `os.UserCacheDir()/museguessr/`.
42+- `audio.go` — ffmpeg decodes the first `introWindow` (30s) of a file to
43+ mono 44.1kHz s16le; `findStart` locates where RMS crosses `-threshold`
44+ in 10ms windows, falling back to 10% of the window's peak RMS if the
45+ threshold is never reached (handles long quiet intros); the segment is
46+ written as a temp WAV and played with `aplay`. Requires ffmpeg and aplay
47+ on PATH.
48+- `matching.go` — `fuzzyMatch`: normalized similarity (title 4 pts, artist
49+ 2 pts) with a parenthesized-suffix retry; `matchThreshold = 0.8`.
50+- `ui.go` — bubbletea TUI: model, game states (playing/guess/result),
51+ keyboard handling (Tab, Enter, Esc, Ctrl+R replay, Ctrl+L 2s hint),
52+ scoring, and the `startAudio` tea.Cmd that runs playback in a goroutine.
53+- `ui_test.go`, `matching_test.go` — unit tests for game flow and matching.
54+- `blacklist` — local blacklist file (one directory per line).
55+
56+## Conventions
57+
58+- Do NOT add new CLI flags, config options, or user-facing knobs unless
59+ explicitly asked. Hardcode reasonable constants (e.g. `introWindow`)
60+ instead.
61+- Keep the single-package layout; don't split into subpackages.
62+- External dependencies are ffmpeg and aplay — shell out to them rather
63+ than adding Go audio libraries.
64+- Build with `go build ./...` and run `go test ./...` after changes.
65diff --git a/README.md b/README.md
66new file mode 100644
67index 0000000000000000000000000000000000000000..7fb07f4124b84bcc2aff183ed468737523306aec
68--- /dev/null
69+++ b/README.md
70@@ -0,0 +1,69 @@
71+# Muse Guessr
72+
73+A simple CLI music guessing game built with Go and [bubbletea](https://github.com/charmbracelet/bubbletea).
74+
75+It scans a directory of music files, plays a one-second clip from each song, and
76+you guess the title and artist.
77+
78+## How it works
79+
80+1. **Catalog** — walks the given directory recursively, keeps common music file
81+ types, skips files under any blacklisted directory, and reads each file's title/artist
82+ tags (via [`dhowden/tag`](https://github.com/dhowden/tag)). Files without
83+ readable tags fall back to the filename as the title.
84+2. **Clip** — decodes the first ~10 seconds of a song to mono PCM with `ffmpeg`,
85+ finds the point where the RMS volume first crosses a threshold (so quiet,
86+ slow intros are skipped), and plays the following one second via `aplay`.
87+3. **Guess** — the TUI shows two input fields (title, artist). You type your
88+ answer and submit. Matching ignores casing, punctuation, and extra whitespace,
89+ allows typos, and accepts omitted parenthesized extras such as `(Live)`.
90+ The similarity threshold is `matchThreshold = 0.8` in `matching.go`.
91+4. **Score** — earn 1 point for the title and 1 for the artist, independently.
92+ Each round is worth up to 2 points. Blank answers earn no points.
93+
94+Songs are played in a shuffled order.
95+
96+## Requirements
97+
98+- Go (to build)
99+- `ffmpeg` on `PATH` (decoding)
100+- `aplay` on `PATH` (playback, from ALSA)
101+
102+## Build
103+
104+```sh
105+go build -o museguessr .
106+```
107+
108+## Usage
109+
110+```sh
111+museguessr -dir /path/to/music [-blacklist blacklist.txt] [-threshold 2000] [-rounds 10]
112+```
113+
114+| Flag | Default | Description |
115+| ------------ | ------------ | ------------------------------------------------------------------ |
116+| `-dir` | `.` | Directory to scan for music files (searched recursively). |
117+| `-blacklist` | `blacklist` | File with one directory per line; every file under it is excluded. |
118+| `-threshold` | `2000` | RMS level (0–32767) that marks the start of "loud enough" audio. |
119+| `-rounds` | catalog size | Number of songs to play. `0` means one per song in the catalog. |
120+
121+### Blacklist file
122+
123+One **directory** per line — every file under that directory is excluded.
124+Entries may be absolute, or relative to `-dir`. `#` starts a comment.
125+
126+```
127+# skip these folders entirely
128+Remixes
129+./Old Demos
130+/home/pavle/music/Karaoke
131+```
132+
133+### Controls (in the TUI)
134+
135+- `Tab` — switch between the title and artist fields
136+- `Ctrl+R` — replay the 1-second clip
137+- `Ctrl+L` — play a longer 2-second clip
138+- `Enter` — submit your guess / advance to the next song
139+- `Esc` — skip ahead while a clip is playing, or quit on the final screen
140diff --git a/audio.go b/audio.go
141new file mode 100644
142index 0000000000000000000000000000000000000000..676731098d6a69cd857814bb4fe227bc8d86a831
143--- /dev/null
144+++ b/audio.go
145@@ -0,0 +1,160 @@
146+package main
147+
148+import (
149+ "bytes"
150+ "encoding/binary"
151+ "fmt"
152+ "math"
153+ "os"
154+ "os/exec"
155+ "path/filepath"
156+)
157+
158+const (
159+ sampleRate = 44100
160+ introWindow = 30 // seconds of file decoded for start detection
161+)
162+
163+// decodeIntro decodes the first maxSec seconds of a file into mono s16le PCM.
164+func decodeIntro(path string, maxSec int) ([]int16, error) {
165+ cmd := exec.Command("ffmpeg", "-nostdin", "-loglevel", "error",
166+ "-i", path,
167+ "-t", fmt.Sprintf("%d", maxSec),
168+ "-ac", "1",
169+ "-ar", fmt.Sprintf("%d", sampleRate),
170+ "-f", "s16le", "-")
171+
172+ var out bytes.Buffer
173+ cmd.Stdout = &out
174+ if err := cmd.Run(); err != nil {
175+ return nil, fmt.Errorf("ffmpeg decode: %w", err)
176+ }
177+
178+ data := out.Bytes()
179+ n := len(data) / 2
180+ samples := make([]int16, n)
181+ for i := 0; i < n; i++ {
182+ samples[i] = int16(binary.LittleEndian.Uint16(data[2*i:]))
183+ }
184+ return samples, nil
185+}
186+
187+// findStart returns the sample offset where the RMS level first crosses
188+// threshold, so quiet intros are skipped. It scans in 10ms windows. If the
189+// audio never reaches threshold, it falls back to 10% of the window's peak
190+// RMS, and to 0 if the audio is silent throughout.
191+func findStart(samples []int16, threshold int) int {
192+ win := sampleRate / 100 // 10ms
193+ if win < 1 {
194+ win = 1
195+ }
196+
197+ rmsVals := make([]float64, 0, len(samples)/win)
198+ peak := 0.0
199+ for i := 0; i+win <= len(samples); i += win {
200+ v := rms(samples[i:i+win])
201+ rmsVals = append(rmsVals, v)
202+ if v > peak {
203+ peak = v
204+ }
205+ }
206+
207+ target := float64(threshold)
208+ if peak < target {
209+ target = peak * 0.1
210+ }
211+ for i, v := range rmsVals {
212+ if v >= target {
213+ return i * win
214+ }
215+ }
216+ return 0
217+}
218+
219+// rms computes the root-mean-square amplitude of a sample slice.
220+func rms(s []int16) float64 {
221+ if len(s) == 0 {
222+ return 0
223+ }
224+ var sum int64
225+ for _, v := range s {
226+ sum += int64(v) * int64(v)
227+ }
228+ return math.Sqrt(float64(sum) / float64(len(s)))
229+}
230+
231+// playSegment decodes the intro of songPath, locates where the volume crosses
232+// threshold, and plays the following seconds via aplay.
233+func playSegment(songPath string, threshold, seconds int) error {
234+ samples, err := decodeIntro(songPath, introWindow)
235+ if err != nil {
236+ return err
237+ }
238+ if len(samples) == 0 {
239+ return fmt.Errorf("no audio decoded from %s", songPath)
240+ }
241+
242+ start := findStart(samples, threshold)
243+ end := start + seconds*sampleRate
244+ if end > len(samples) {
245diff --git a/catalog.go b/catalog.go
246new file mode 100644
247index 0000000000000000000000000000000000000000..34dc52f724ebf6a85f5db6c62c4d64c97f983ad4
248--- /dev/null
249+++ b/catalog.go
250@@ -0,0 +1,222 @@
251+package main
252+
253+import (
254+ "bufio"
255+ "encoding/json"
256+ "fmt"
257+ "hash/fnv"
258+ "io"
259+ "os"
260+ "path/filepath"
261+ "strings"
262+
263+ "github.com/dhowden/tag"
264+)
265+
266+// Song is a catalog entry: the file path plus its metadata.
267+type Song struct {
268+ Path string
269+ Title string
270+ Artist string
271+}
272+
273+var musicExts = map[string]bool{
274+ ".mp3": true, ".flac": true, ".ogg": true, ".oga": true,
275+ ".m4a": true, ".aac": true, ".wav": true, ".opus": true,
276+ ".wma": true, ".aiff": true, ".aif": true,
277+}
278+
279+// buildCatalog walks dir recursively, keeps music files not on the blacklist,
280+// and reads their title/artist tags.
281+// catalogStats summarises how the catalog was assembled, so the caller can
282+// report how much came from the cache versus a fresh tag read.
283+type catalogStats struct {
284+ Total int
285+ FromCache int
286+ Scanned int
287+}
288+
289+// buildCatalog walks dir recursively, keeps music files not under a
290+// blacklisted directory, and produces the song catalog. Tag reading is the
291+// slow part, so results are cached on disk keyed by file mtime: a fast walk
292+// (stat only) collects the current files, unchanged files reuse their cached
293+// tags, and only new or modified files are re-parsed. onProgress is called
294+// (if non-nil) for every file as it is added to the catalog.
295+func buildCatalog(dir, blacklistPath string, onProgress func(count int, current string)) ([]Song, catalogStats, error) {
296+ absDir, err := filepath.Abs(dir)
297+ if err != nil {
298+ return nil, catalogStats{}, err
299+ }
300+ blacklistDirs, err := loadBlacklistDirs(absDir, blacklistPath)
301+ if err != nil {
302+ return nil, catalogStats{}, err
303+ }
304+
305+ // Fast walk: collect current music files and their mtimes (no tag parsing).
306+ type fileRef struct {
307+ path string
308+ mtime int64
309+ }
310+ var current []fileRef
311+ if walkErr := filepath.Walk(absDir, func(path string, info os.FileInfo, err error) error {
312+ if err != nil || info.IsDir() {
313+ return nil
314+ }
315+ if !musicExts[strings.ToLower(filepath.Ext(path))] {
316+ return nil
317+ }
318+ if isExcluded(path, blacklistDirs) {
319+ return nil
320+ }
321+ current = append(current, fileRef{path, info.ModTime().UnixNano()})
322+ return nil
323+ }); walkErr != nil {
324+ return nil, catalogStats{}, walkErr
325+ }
326+
327+ cached := loadCache(cacheFilePath(absDir, blacklistPath))
328+
329+ songs := make([]Song, 0, len(current))
330+ newCache := make(map[string]cacheEntry, len(current))
331+ stats := catalogStats{Total: len(current)}
332+ count := 0
333+ for _, fr := range current {
334+ count++
335+ if onProgress != nil {
336+ onProgress(count, filepath.Base(fr.path))
337+ }
338+ if e, ok := cached[fr.path]; ok && e.Mtime == fr.mtime {
339+ songs = append(songs, Song{Path: fr.path, Title: e.Title, Artist: e.Artist})
340+ newCache[fr.path] = e
341+ stats.FromCache++
342+ continue
343+ }
344+ s := readSong(fr.path)
345+ songs = append(songs, s)
346+ newCache[fr.path] = cacheEntry{Title: s.Title, Artist: s.Artist, Mtime: fr.mtime}
347+ stats.Scanned++
348+ }
349+
350diff --git a/go.mod b/go.mod
351new file mode 100644
352index 0000000000000000000000000000000000000000..dd43aa1048552740d35e64a154bd14819f6e4eb0
353--- /dev/null
354+++ b/go.mod
355@@ -0,0 +1,35 @@
356+module museguessr
357+
358+go 1.27.2
359+
360+require (
361+ github.com/charmbracelet/bubbles v1.0.0
362+ github.com/charmbracelet/bubbletea v1.3.10
363+ github.com/charmbracelet/lipgloss v1.1.0
364+ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
365+)
366+
367+require (
368+ github.com/atotto/clipboard v0.1.4 // indirect
369+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
370+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
371+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
372+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
373+ github.com/charmbracelet/x/term v0.2.2 // indirect
374+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
375+ github.com/clipperhouse/stringish v0.1.1 // indirect
376+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
377+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
378+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
379+ github.com/mattn/go-isatty v0.0.20 // indirect
380+ github.com/mattn/go-localereader v0.0.1 // indirect
381+ github.com/mattn/go-runewidth v0.0.19 // indirect
382+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
383+ github.com/muesli/cancelreader v0.2.2 // indirect
384+ github.com/muesli/termenv v0.16.0 // indirect
385+ github.com/rivo/uniseg v0.4.7 // indirect
386+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
387+ golang.org/x/sys v0.48.0 // indirect
388+ golang.org/x/text v0.3.8 // indirect
389+ golang.org/x/tools v0.51.0 // indirect
390+)
391diff --git a/go.sum b/go.sum
392new file mode 100644
393index 0000000000000000000000000000000000000000..f50493751b9ea5206e76014c1a4c30db57b6b84f
394--- /dev/null
395+++ b/go.sum
396@@ -0,0 +1,58 @@
397+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
398+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
399+github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
400+github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
401+github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
402+github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
403+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
404+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
405+github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
406+github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
407+github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
408+github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
409+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
410+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
411+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
412+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
413+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
414+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
415+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
416+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
417+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
418+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
419+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
420+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
421+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
422+github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
423+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
424+github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
425+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
426+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
427+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
428+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
429+github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
430+github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
431+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
432+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
433+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
434+github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
435+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
436+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
437+github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
438+github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
439+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
440+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
441+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
442+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
443+golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
444+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
445+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
446+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
447+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
448+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
449+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
450+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
451+golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
452+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
453+golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
454+golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
455diff --git a/main.go b/main.go
456new file mode 100644
457index 0000000000000000000000000000000000000000..586af4a857cd7d00530ab87a7e7f9a5fc8ae4db8
458--- /dev/null
459+++ b/main.go
460@@ -0,0 +1,42 @@
461+package main
462+
463+import (
464+ "flag"
465+ "fmt"
466+ "os"
467+
468+ tea "github.com/charmbracelet/bubbletea"
469+)
470+
471+func main() {
472+ dir := flag.String("dir", "/mnt/media/music", "directory to scan for music files")
473+ blacklist := flag.String("blacklist", "blacklist", "path to blacklist file (one directory per line, fully excluded)")
474+ threshold := flag.Int("threshold", 2000, "RMS volume threshold (0-32767) used to skip quiet intros")
475+ rounds := flag.Int("rounds", 10, "number of rounds")
476+ flag.Parse()
477+
478+ fmt.Printf("Scanning %s …\n", *dir)
479+ catalog, stats, err := buildCatalog(*dir, *blacklist, func(n int, current string) {
480+ fmt.Printf("\r %d files (now: %s)\x1b[K", n, current)
481+ })
482+ if err != nil {
483+ fmt.Fprintf(os.Stderr, "\nerror building catalog: %v\n", err)
484+ os.Exit(1)
485+ }
486+ fmt.Printf("\nFound %d songs (%d cached, %d new).\n", stats.Total, stats.FromCache, stats.Scanned)
487+ if len(catalog) == 0 {
488+ fmt.Fprintln(os.Stderr, "no songs found in the catalog")
489+ os.Exit(1)
490+ }
491+
492+ if *rounds <= 0 {
493+ *rounds = len(catalog)
494+ }
495+
496+ m := newModel(catalog, *threshold, *rounds)
497+ p := tea.NewProgram(m, tea.WithAltScreen())
498+ if _, err := p.Run(); err != nil {
499+ fmt.Fprintf(os.Stderr, "error running TUI: %v\n", err)
500+ os.Exit(1)
501+ }
502+}
503diff --git a/matching.go b/matching.go
504new file mode 100644
505index 0000000000000000000000000000000000000000..64db2a840de33c6de6b804bbe658c2195eb7960c
506--- /dev/null
507+++ b/matching.go
508@@ -0,0 +1,75 @@
509+package main
510+
511+import (
512+ "strings"
513+ "unicode"
514+)
515+
516+const matchThreshold = 0.7
517+const (
518+ pointsTitle = 4
519+ pointsArtist = 2
520+ pointsPerRound = pointsTitle + pointsArtist
521+)
522+
523+func fuzzyMatch(guess, answer string) bool {
524+ return similarity(normalize(guess), normalize(answer)) >= matchThreshold ||
525+ similarity(normalize(withoutParentheses(guess)), normalize(withoutParentheses(answer))) >= matchThreshold
526+}
527+
528+func withoutParentheses(s string) string {
529+ var b strings.Builder
530+ depth := 0
531+ for _, r := range s {
532+ switch r {
533+ case '(':
534+ depth++
535+ b.WriteRune(' ')
536+ case ')':
537+ if depth > 0 {
538+ depth--
539+ }
540+ b.WriteRune(' ')
541+ default:
542+ if depth == 0 {
543+ b.WriteRune(r)
544+ }
545+ }
546+ }
547+ return b.String()
548+}
549+
550+func normalize(s string) string {
551+ var b strings.Builder
552+ for _, r := range strings.ToLower(s) {
553+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
554+ b.WriteRune(r)
555+ }
556+ }
557+ return b.String()
558+}
559+
560+func similarity(a, b string) float64 {
561+ x, y := []rune(a), []rune(b)
562+ if len(x) == 0 || len(y) == 0 {
563+ return 0
564+ }
565+ row := make([]int, len(y)+1)
566+ for j := range row {
567+ row[j] = j
568+ }
569+ for i, r := range x {
570+ previous := row[0]
571+ row[0] = i + 1
572+ for j, s := range y {
573+ cost := 0
574+ if r != s {
575+ cost = 1
576+ }
577+ old := row[j+1]
578+ row[j+1] = min(row[j]+1, old+1, previous+cost)
579+ previous = old
580+ }
581+ }
582+ return 1 - float64(row[len(y)])/float64(max(len(x), len(y)))
583+}
584diff --git a/matching_test.go b/matching_test.go
585new file mode 100644
586index 0000000000000000000000000000000000000000..0ba358ffd7244f178c8337a7b56200e9cf064f8f
587--- /dev/null
588+++ b/matching_test.go
589@@ -0,0 +1,59 @@
590+package main
591+
592+import "testing"
593+
594+func TestFuzzyMatch(t *testing.T) {
595+ for _, tt := range []struct {
596+ guess, answer string
597+ want bool
598+ }{
599+ {"HELLO", "Hello", true},
600+ {"Bohemain Rhapsody", "Bohemian Rhapsody", true},
601+ {"Song", "Song (Live at Wembley)", true},
602+ {"Song Live at Wembley", "Song (Live at Wembley)", true},
603+ {"Song (Live)", "Song", true},
604+ {"Beyoncè", "Beyoncé", true},
605+ {"hello", "hallo", true},
606+ {"Getaway", "The Getaway", true},
607+ {"hello", "hxllo!", true},
608+ {"hello", "hxxlo", false},
609+ {"unrelated", "Bohemian Rhapsody", false},
610+ {"", "", false},
611+ {"", "Artist", false},
612+ {"(Live)", "Song (Live)", false},
613+ {"Song", "Songbird", false},
614+ } {
615+ t.Run(tt.guess+"/"+tt.answer, func(t *testing.T) {
616+ if got := fuzzyMatch(tt.guess, tt.answer); got != tt.want {
617+ t.Fatalf("fuzzyMatch(%q, %q) = %v, want %v", tt.guess, tt.answer, got, tt.want)
618+ }
619+ })
620+ }
621+}
622+
623+func TestGuessScoring(t *testing.T) {
624+ for _, tt := range []struct {
625+ title, artist string
626+ hinted bool
627+ points int
628+ message string
629+ }{
630+ {"Hello", "Adele", false, 6, "Correct! +6 points"},
631+ {"Wrong", "Adele", false, 2, "Artist correct! +2 points"},
632+ {"Hello", "Wrong", false, 4, "Title correct! +4 points"},
633+ {"Wrong", "Wrong", false, 0, "Wrong."},
634+ {"", "", false, 0, "Wrong."},
635+ {"Hello", "Adele", true, 3, "Correct! +3 points (halved for 2s hint)"},
636+ {"Hello", "Wrong", true, 2, "Title correct! +2 points (halved for 2s hint)"},
637+ {"Wrong", "Adele", true, 1, "Artist correct! +1 point (halved for 2s hint)"},
638+ } {
639+ m := newModel([]Song{{Title: "Hello", Artist: "Adele"}}, 500, 1)
640+ m.titleInput.SetValue(tt.title)
641+ m.artistInput.SetValue(tt.artist)
642+ m.hinted = tt.hinted
643+ m.submitGuess()
644+ if m.score != tt.points || m.resultMsg != tt.message || m.st != stateResult {
645+ t.Fatalf("guess %q / %q (hinted=%v): score=%d, message=%q, state=%v", tt.title, tt.artist, tt.hinted, m.score, m.resultMsg, m.st)
646+ }
647+ }
648+}
649diff --git a/ui.go b/ui.go
650new file mode 100644
651index 0000000000000000000000000000000000000000..ad4a5494e8b62a036671b342cc4ef18c718fbf53
652--- /dev/null
653+++ b/ui.go
654@@ -0,0 +1,314 @@
655+package main
656+
657+import (
658+ "fmt"
659+ "math/rand"
660+ "strings"
661+
662+ "github.com/charmbracelet/bubbles/textinput"
663+ tea "github.com/charmbracelet/bubbletea"
664+ "github.com/charmbracelet/lipgloss"
665+)
666+
667+type state int
668+
669+const (
670+ statePlaying state = iota
671+ stateGuess
672+ stateResult
673+)
674+
675+// audioDoneMsg is sent back once playback of the segment finishes.
676+type audioDoneMsg struct{}
677+
678+type model struct {
679+ catalog []Song
680+ order []int
681+ pos int
682+ rounds int
683+ played int
684+ score int
685+ correct int
686+ threshold int
687+
688+ titleInput textinput.Model
689+ artistInput textinput.Model
690+ active int // 0 = title, 1 = artist
691+
692+ st state
693+ playing bool
694+ hinted bool
695+ answer Song
696+ resultMsg string
697+ done bool
698+}
699+
700+func newModel(catalog []Song, threshold, rounds int) model {
701+ ti := textinput.New()
702+ ti.Prompt = "Title: "
703+ tt := textinput.New()
704+ tt.Prompt = "Artist: "
705+
706+ m := model{
707+ catalog: catalog,
708+ rounds: rounds,
709+ threshold: threshold,
710+ titleInput: ti,
711+ artistInput: tt,
712+ order: shuffled(len(catalog)),
713+ st: statePlaying,
714+ playing: true,
715+ active: 0,
716+ }
717+ // Pre-select the first song so Init can return its playback command.
718+ m.answer = catalog[m.order[0]]
719+ m.pos = 1
720+ m.titleInput.Focus()
721+ m.artistInput.Blur()
722+ return m
723+}
724+
725+func (m model) Init() tea.Cmd {
726+ return startAudio(m.answer, m.threshold, 1)
727+}
728+
729+// startAudio is a command that plays the one-second segment of song and then
730+// reports back with audioDoneMsg. bubbletea runs it in a goroutine, so the TUI
731+// stays responsive while the audio plays.
732+func startAudio(song Song, threshold, seconds int) tea.Cmd {
733+ return func() tea.Msg {
734+ _ = playSegment(song.Path, threshold, seconds)
735+ return audioDoneMsg{}
736+ }
737+}
738+
739+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
740+ switch msg := msg.(type) {
741+ case audioDoneMsg:
742+ m.playing = false
743+ if m.st == statePlaying {
744+ m.st = stateGuess
745+ }
746+ return m, nil
747+
748+ case tea.KeyMsg:
749+ if msg.Type == tea.KeyCtrlC {
750+ return m, tea.Quit
751+ }
752+ switch m.st {
753+ case statePlaying:
754diff --git a/ui_test.go b/ui_test.go
755new file mode 100644
756index 0000000000000000000000000000000000000000..48903bce22360db7cbe1b64148963bae83092542
757--- /dev/null
758+++ b/ui_test.go
759@@ -0,0 +1,45 @@
760+package main
761+
762+import (
763+ "testing"
764+
765+ tea "github.com/charmbracelet/bubbletea"
766+)
767+
768+func TestReplay(t *testing.T) {
769+ m := newModel([]Song{{Title: "Song", Artist: "Artist", Path: "song.mp3"}}, 500, 1)
770+ updated, _ := m.Update(audioDoneMsg{})
771+ m = updated.(model)
772+ m.titleInput.SetValue("my title")
773+ m.artistInput.SetValue("my artist")
774+ updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
775+ m = updated.(model)
776+ if cmd == nil || !m.playing || m.st != stateGuess {
777+ t.Fatal("replay should start playback in the guessing screen")
778+ }
779+ _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
780+ if cmd != nil {
781+ t.Fatal("replay should not start overlapping playback")
782+ }
783+ updated, _ = m.Update(audioDoneMsg{})
784+ m = updated.(model)
785+ 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 {
786+ t.Fatal("replay should preserve guesses, focus, and round progress")
787+ }
788+ updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
789+ m = updated.(model)
790+ if cmd == nil || !m.playing {
791+ t.Fatal("Ctrl+L should start playback")
792+ }
793+ updated, _ = m.Update(audioDoneMsg{})
794+ m = updated.(model)
795+ if m.playing || m.titleInput.Value() != "my title" || m.artistInput.Value() != "my artist" {
796+ t.Fatal("longer replay should preserve guesses")
797+ }
798+
799+ updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
800+ m = updated.(model)
801+ if m.active != 1 || !m.artistInput.Focused() {
802+ t.Fatal("Tab should switch to the artist field")
803+ }
804+}