28717836a47d36bdf25b1f8ae4b3fc65ada011d3

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

Message

Adjust volume threshold dynamically

Diff

This diff is truncated to protect this page.

  1diff --git a/README.md b/README.md
  2index 7fb07f4124b84bcc2aff183ed468737523306aec..a31d748ea38a412a570e7f8efde072d2dc51f104 100644
  3--- a/README.md
  4+++ b/README.md
  5@@ -11,9 +11,10 @@ you guess the title and artist.
  6    types, skips files under any blacklisted directory, and reads each file's title/artist
  7    tags (via [`dhowden/tag`](https://github.com/dhowden/tag)). Files without
  8    readable tags fall back to the filename as the title.
  9-2. **Clip** — decodes the first ~10 seconds of a song to mono PCM with `ffmpeg`,
 10-   finds the point where the RMS volume first crosses a threshold (so quiet,
 11-   slow intros are skipped), and plays the following one second via `aplay`.
 12+2. **Clip** — decodes the first 30 seconds of a song to mono PCM with `ffmpeg`,
 13+   finds the point where the RMS volume first crosses the average volume of
 14+   that window (so quiet, slow intros are skipped), and plays the following
 15+   one second via `aplay`.
 16 3. **Guess** — the TUI shows two input fields (title, artist). You type your
 17    answer and submit. Matching ignores casing, punctuation, and extra whitespace,
 18    allows typos, and accepts omitted parenthesized extras such as `(Live)`.
 19@@ -38,14 +39,13 @@ go build -o museguessr .
 20 ## Usage
 21 
 22 ```sh
 23-museguessr -dir /path/to/music [-blacklist blacklist.txt] [-threshold 2000] [-rounds 10]
 24+museguessr -dir /path/to/music [-blacklist blacklist.txt] [-rounds 10]
 25 ```
 26 
 27 | Flag         | Default      | Description                                                        |
 28 | ------------ | ------------ | ------------------------------------------------------------------ |
 29 | `-dir`       | `.`          | Directory to scan for music files (searched recursively).          |
 30 | `-blacklist` | `blacklist`  | File with one directory per line; every file under it is excluded. |
 31-| `-threshold` | `2000`       | RMS level (0–32767) that marks the start of "loud enough" audio.   |
 32 | `-rounds`    | catalog size | Number of songs to play. `0` means one per song in the catalog.    |
 33 
 34 ### Blacklist file
 35diff --git a/audio.go b/audio.go
 36index 842c3884342b0e5a25f61ed6ed645f25f11cc036..a60705ae2ff803b4289f4c3d8ca345483c859ba7 100644
 37--- a/audio.go
 38+++ b/audio.go
 39@@ -39,32 +39,28 @@ func decodeIntro(path string, maxSec int) ([]int16, error) {
 40 	return samples, nil
 41 }
 42 
 43-// findStart returns the sample offset where the RMS level first crosses
 44-// threshold, so quiet intros are skipped. It scans in 10ms windows. If the
 45-// audio never reaches threshold, it falls back to 10% of the window's peak
 46-// RMS, and to 0 if the audio is silent throughout.
 47-func findStart(samples []int16, threshold int) int {
 48+// findStart returns the sample offset where the RMS level first crosses the
 49+// average RMS of the decoded window, so quiet intros are skipped. It scans
 50+// in 10ms windows and falls back to 0 if the audio is silent throughout.
 51+func findStart(samples []int16) int {
 52 	win := sampleRate / 100 // 10ms
 53 	if win < 1 {
 54 		win = 1
 55 	}
 56 
 57 	rmsVals := make([]float64, 0, len(samples)/win)
 58-	peak := 0.0
 59+	var sum float64
 60 	for i := 0; i+win <= len(samples); i += win {
 61 		v := rms(samples[i:i+win])
 62 		rmsVals = append(rmsVals, v)
 63-		if v > peak {
 64-			peak = v
 65-		}
 66+		sum += v
 67 	}
 68-
 69-	target := float64(threshold)
 70-	if peak < target {
 71-		target = peak * 0.1
 72+	if len(rmsVals) == 0 {
 73+		return 0
 74 	}
 75+	avg := sum / float64(len(rmsVals))
 76 	for i, v := range rmsVals {
 77-		if v >= target {
 78+		if v >= avg {
 79 			return i * win
 80 		}
 81 	}
 82@@ -84,8 +80,8 @@ func rms(s []int16) float64 {
 83 }
 84 
 85 // playSegment decodes the intro of songPath, locates where the volume crosses
 86-// threshold, and plays the following seconds via aplay.
 87-func playSegment(songPath string, threshold, seconds int) error {
 88+// the window average, and plays the following seconds via aplay.
 89+func playSegment(songPath string, seconds int) error {
 90 	samples, err := decodeIntro(songPath, introWindow)
 91 	if err != nil {
 92 		return err
 93@@ -94,7 +90,7 @@ func playSegment(songPath string, threshold, seconds int) error {
 94 		return fmt.Errorf("no audio decoded from %s", songPath)
 95 	}
 96 
 97-	start := findStart(samples, threshold)
 98+	start := findStart(samples)
 99 	end := start + seconds*sampleRate
100 	if end > len(samples) {
101 		end = len(samples)
102diff --git a/catalog.go b/catalog.go
103index 75adaaf28d9114fe4c473647cda1d52a646c5a2e..1314e1f73d11548bc52bb112458f8647fd455ba7 100644
104--- a/catalog.go
105+++ b/catalog.go
106@@ -42,8 +42,8 @@ type catalogStats struct {
107 // slow part, so results are cached on disk keyed by file mtime: a fast walk
108 // (stat only) collects the current files, unchanged files reuse their cached
109 // tags, and only new or modified files are re-parsed. onProgress is called
110-// (if non-nil) for every file as it is added to the catalog.
111-func buildCatalog(dir, blacklistPath string, onProgress func(count int, current string)) ([]Song, catalogStats, error) {
112+// (if non-nil) with (count, total) for every file added to the catalog.
113+func buildCatalog(dir, blacklistPath string, onProgress func(count, total int)) ([]Song, catalogStats, error) {
114 	absDir, err := filepath.Abs(dir)
115 	if err != nil {
116 		return nil, catalogStats{}, err
117@@ -84,7 +84,7 @@ func buildCatalog(dir, blacklistPath string, onProgress func(count int, current
118 	for _, fr := range current {
119 		count++
120 		if onProgress != nil {
121-			onProgress(count, filepath.Base(fr.path))
122+			onProgress(count, len(current))
123 		}
124 		if e, ok := cached[fr.path]; ok && e.Mtime == fr.mtime {
125 			songs = append(songs, Song{Path: fr.path, Title: e.Title, Artist: e.Artist, Album: e.Album})
126diff --git a/main.go b/main.go
127index 586af4a857cd7d00530ab87a7e7f9a5fc8ae4db8..0644c5b0f3b94123683e31903c58dd1b7a8b51f6 100644
128--- a/main.go
129+++ b/main.go
130@@ -11,13 +11,12 @@ import (
131 func main() {
132 	dir := flag.String("dir", "/mnt/media/music", "directory to scan for music files")
133 	blacklist := flag.String("blacklist", "blacklist", "path to blacklist file (one directory per line, fully excluded)")
134-	threshold := flag.Int("threshold", 2000, "RMS volume threshold (0-32767) used to skip quiet intros")
135 	rounds := flag.Int("rounds", 10, "number of rounds")
136 	flag.Parse()
137 
138 	fmt.Printf("Scanning %s …\n", *dir)
139-	catalog, stats, err := buildCatalog(*dir, *blacklist, func(n int, current string) {
140-		fmt.Printf("\r  %d files (now: %s)\x1b[K", n, current)
141+	catalog, stats, err := buildCatalog(*dir, *blacklist, func(n, total int) {
142+		fmt.Printf("\r  %d%%\x1b[K", n*100/total)
143 	})
144 	if err != nil {
145 		fmt.Fprintf(os.Stderr, "\nerror building catalog: %v\n", err)
146@@ -33,7 +32,7 @@ func main() {
147 		*rounds = len(catalog)
148 	}
149 
150-	m := newModel(catalog, *threshold, *rounds)
151+	m := newModel(catalog, *rounds)
152 	p := tea.NewProgram(m, tea.WithAltScreen())
153 	if _, err := p.Run(); err != nil {
154 		fmt.Fprintf(os.Stderr, "error running TUI: %v\n", err)
155diff --git a/matching_test.go b/matching_test.go
156index ab45bd87b4a840672121ccd408dd1bb47332447e..7d9ed3ad3043262f7f070b2718bb70c561684eca 100644
157--- a/matching_test.go
158+++ b/matching_test.go
159@@ -48,7 +48,7 @@ func TestGuessScoring(t *testing.T) {
160 		{"Hello", "Wrong", "21", true, 4, "Title, Album correct! +4 points (halved for 2s hint)"},
161 		{"Wrong", "Adele", "21", true, 3, "Artist, Album correct! +3 points (halved for 2s hint)"},
162 	} {
163-		m := newModel([]Song{{Title: "Hello", Artist: "Adele", Album: "21"}}, 500, 1)
164+		m := newModel([]Song{{Title: "Hello", Artist: "Adele", Album: "21"}}, 1)
165 		m.titleInput.SetValue(tt.title)
166 		m.artistInput.SetValue(tt.artist)
167 		m.albumInput.SetValue(tt.album)
168diff --git a/ui.go b/ui.go
169index 29f40626228e5f914dcb464db205d23bd11ad21e..0c9f79a9e3fb5acf3a30b63dccdebc968b7dd8f2 100644
170--- a/ui.go
171+++ b/ui.go
172@@ -29,12 +29,11 @@ type model struct {
173 	played    int
174 	score     int
175 	correct   int
176-	threshold int
177 
178 	titleInput  textinput.Model
179 	artistInput textinput.Model
180 	albumInput  textinput.Model
181-	active      int // 0 = title, 1 = artist, 2 = album
182+	active      int // 0 = title, 1 = album, 2 = artist
183 
184 	st        state
185 	playing   bool
186@@ -44,7 +43,7 @@ type model struct {
187 	done      bool
188 }
189 
190-func newModel(catalog []Song, threshold, rounds int) model {
191+func newModel(catalog []Song, rounds int) model {
192 	ti := textinput.New()
193 	ti.Prompt = "Title:  "
194 	tt := textinput.New()
195@@ -55,7 +54,6 @@ func newModel(catalog []Song, threshold, rounds int) model {
196 	m := model{
197 		catalog:     catalog,
198 		rounds:      rounds,
199-		threshold:   threshold,
200 		titleInput:  ti,
201 		artistInput: tt,
202 		albumInput:  ta,
203@@ -73,15 +71,15 @@ func newModel(catalog []Song, threshold, rounds int) model {
204 }
205 
206 func (m model) Init() tea.Cmd {
207-	return startAudio(m.answer, m.threshold, 1)
208+	return startAudio(m.answer, 1)
209 }
210 
211 // startAudio is a command that plays the one-second segment of song and then
212 // reports back with audioDoneMsg. bubbletea runs it in a goroutine, so the TUI
213 // stays responsive while the audio plays.
214-func startAudio(song Song, threshold, seconds int) tea.Cmd {
215+func startAudio(song Song, seconds int) tea.Cmd {
216 	return func() tea.Msg {
217-		_ = playSegment(song.Path, threshold, seconds)
218+		_ = playSegment(song.Path, seconds)
219 		return audioDoneMsg{}
220 	}
221 }
222@@ -117,14 +115,14 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223 					return m, nil
224 				}
225 				m.playing = true
226-				return m, startAudio(m.answer, m.threshold, 1)
227+				return m, startAudio(m.answer, 1)
228 			case tea.KeyCtrlL:
229 				if m.playing {
230 					return m, nil
231 				}
232 				m.playing = true
233 				m.hinted = true
234-				return m, startAudio(m.answer, m.threshold, 2)
235+				return m, startAudio(m.answer, 2)
236 			case tea.KeyEnter:
237 				if m.playing {
238 					return m, nil
239@@ -135,9 +133,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
240 				case 0:
241 					m.titleInput, _ = m.titleInput.Update(msg)
242 				case 1:
243-					m.artistInput, _ = m.artistInput.Update(msg)
244-				default:
245 					m.albumInput, _ = m.albumInput.Update(msg)
246+				default:
247+					m.artistInput, _ = m.artistInput.Update(msg)
248 				}
249 				return m, nil
250 			}
251@@ -180,21 +178,21 @@ func (m *model) beginRound(song Song) tea.Cmd {
252 	m.titleInput.Focus()
253 	m.artistInput.Blur()
254 	m.albumInput.Blur()
255-	return startAudio(song, m.threshold, 1)
256+	return startAudio(song, 1)
257 }
258 
259 func (m *model) toggleField() {
260 	m.active = (m.active + 1) % 3
261-	for _, in := range []textinput.Model{m.titleInput, m.artistInput, m.albumInput} {
262-		in.Blur()
263-	}
264+	m.titleInput.Blur()
265+	m.albumInput.Blur()
266+	m.artistInput.Blur()
267 	switch m.active {
268 	case 0:
269 		m.titleInput.Focus()
270 	case 1:
271-		m.artistInput.Focus()
272diff --git a/ui_test.go b/ui_test.go
273index 48903bce22360db7cbe1b64148963bae83092542..93aa6092c3c7703a5172c15cdd853806ecbc6203 100644
274--- a/ui_test.go
275+++ b/ui_test.go
276@@ -7,7 +7,7 @@ import (
277 )
278 
279 func TestReplay(t *testing.T) {
280-	m := newModel([]Song{{Title: "Song", Artist: "Artist", Path: "song.mp3"}}, 500, 1)
281+	m := newModel([]Song{{Title: "Song", Artist: "Artist", Path: "song.mp3"}}, 1)
282 	updated, _ := m.Update(audioDoneMsg{})
283 	m = updated.(model)
284 	m.titleInput.SetValue("my title")
285@@ -39,7 +39,19 @@ func TestReplay(t *testing.T) {
286 
287 	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
288 	m = updated.(model)
289-	if m.active != 1 || !m.artistInput.Focused() {
290-		t.Fatal("Tab should switch to the artist field")
291+	if m.active != 1 || !m.albumInput.Focused() {
292+		t.Fatal("Tab should switch to the album field")
293+	}
294+
295+	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
296+	m = updated.(model)
297+	if m.active != 2 || !m.artistInput.Focused() {
298+		t.Fatal("second Tab should switch to the artist field")
299+	}
300+
301+	updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab})
302+	m = updated.(model)
303+	if m.active != 0 || !m.titleInput.Focused() {
304+		t.Fatal("third Tab should wrap back to the title field")
305 	}
306 }