4702d55a39521a792c0bc0d9d607e1e2bb6064a7

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

Message

initial commit

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+}