diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a765a42..6238d01 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -34,7 +34,9 @@ jobs: - name: Run golangci-lint uses: golangci/golangci-lint-action@v7 with: - version: latest + # Pinned to avoid CI surprises when new lint releases tighten + # checks. Bump deliberately, not silently. + version: v2.12.2 vet: name: Go Vet & Build diff --git a/.gitignore b/.gitignore index 9865b47..79ec360 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ go.work # Build artifacts /rig +/test-shazam /dist/ /build/ diff --git a/README.md b/README.md index cb6cc57..291e071 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ No accounts. No ads. Just radio. - ๐ŸŽจ Beautiful terminal UI with multiple themes - โŒจ๏ธ Keyboard-driven interface for fast navigation - โญ Save your favourite stations +- ๐ŸŽค Identify the playing track with one keypress โ€” Shazam-style, no API key required - ๐ŸŽต Now playing display with station metadata ### Search and play @@ -37,6 +38,11 @@ No accounts. No ads. Just radio. ![Managing favourites](docs/assets/favourites.gif) +### Identify the playing track +Press `i` while a station is playing. rig taps the audio, fingerprints it locally in pure Go, and asks Shazam to identify it. Press `o` to open the track on shazam.com. + +![Identifying a track in the terminal](docs/assets/identify.gif) + ## Installation @@ -85,6 +91,8 @@ rig | `Space` | Pause/Resume | | `+` / `-` | Volume up/down | | `s` | Search stations | +| `i` | Identify the playing track | +| `o` | Open identified track on shazam.com | | `?` | Help | | `q` | Quit | diff --git a/cmd/test-shazam/main.go b/cmd/test-shazam/main.go new file mode 100644 index 0000000..42e766e --- /dev/null +++ b/cmd/test-shazam/main.go @@ -0,0 +1,113 @@ +// Command test-shazam taps a live radio stream URL, fingerprints ~12 seconds +// of audio, and asks Shazam to identify the track. It exists so the +// identification pipeline can be smoke-tested end-to-end without booting the +// TUI. +// +// Usage: +// +// go run ./cmd/test-shazam [duration_seconds] +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + "strconv" + "time" + + "github.com/mrwhyte/rig/pkg/identifier" + "github.com/mrwhyte/rig/pkg/identifier/shazam" +) + +func main() { + os.Exit(run()) +} + +func run() int { + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [duration_seconds]\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + + if flag.NArg() < 1 { + flag.Usage() + return 2 + } + + streamURL := flag.Arg(0) + duration := identifier.DefaultSampleSeconds * time.Second + if flag.NArg() >= 2 { + secs, err := strconv.Atoi(flag.Arg(1)) + if err != nil || secs <= 0 { + fmt.Fprintf(os.Stderr, "invalid duration_seconds: %q\n", flag.Arg(1)) + return 2 + } + duration = time.Duration(secs) * time.Second + } + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + // Generous overall timeout: capture + network round-trip + Shazam rate limiter. + ctx, cancelTimeout := context.WithTimeout(ctx, duration+30*time.Second) + defer cancelTimeout() + + fmt.Fprintf(os.Stderr, "tapping %s for %s...\n", streamURL, duration) + + captureStart := time.Now() + samples, sampleRate, err := identifier.CaptureMonoSamples(ctx, streamURL, duration) + if err != nil { + fmt.Fprintf(os.Stderr, "capture error: %v\n", err) + return 1 + } + captureWall := time.Since(captureStart) + audioSeconds := float64(len(samples)) / float64(sampleRate) + fmt.Fprintf( + os.Stderr, + "captured %d samples at %d Hz (%.2fs of audio, wall time %s)\n", + len(samples), sampleRate, audioSeconds, captureWall.Round(time.Millisecond), + ) + if audioSeconds < float64(duration/time.Second)*0.9 { + fmt.Fprintf(os.Stderr, "warning: captured less audio than requested โ€” stream may have closed early\n") + } + + resampled := identifier.Resample(samples, sampleRate, identifier.FingerprintSampleRate) + fmt.Fprintf( + os.Stderr, + "resampled to %d samples at %d Hz\n", + len(resampled), identifier.FingerprintSampleRate, + ) + + identifyStart := time.Now() + sig := shazam.ComputeSignature(identifier.FingerprintSampleRate, resampled) + result, err := shazam.Identify(ctx, sig) + if err != nil { + fmt.Fprintf(os.Stderr, "identify error: %v\n", err) + return 1 + } + fmt.Fprintf(os.Stderr, "shazam round trip %s\n", time.Since(identifyStart).Round(time.Millisecond)) + + if !result.Found { + fmt.Fprintln(os.Stderr, "no match") + return 1 + } + + fmt.Printf("Title: %s\n", result.Title) + fmt.Printf("Artist: %s\n", result.Artist) + if result.Album != "" { + fmt.Printf("Album: %s\n", result.Album) + } + if result.Year != "" { + fmt.Printf("Year: %s\n", result.Year) + } + if u := result.ShazamURL(); u != "" { + fmt.Printf("Shazam: %s\n", u) + } + if result.AppleID != "" { + fmt.Printf("AppleID: %s\n", result.AppleID) + } + return 0 +} diff --git a/docs/assets/identify.cast b/docs/assets/identify.cast new file mode 100644 index 0000000..27a90af --- /dev/null +++ b/docs/assets/identify.cast @@ -0,0 +1,54 @@ +{"version":3,"term":{"cols":186,"rows":52,"type":"xterm-ghostty","version":"ghostty 1.3.1","theme":{"fg":"#cccccc","bg":"#1e1e1e","palette":"#000000:#cd3131:#0dbc79:#e5e510:#2472c8:#bc3fbc:#11a8cd:#e5e5e5:#666666:#f14c4c:#23d18b:#f5f543:#3b8eea:#d670d6:#29b8db:#e5e5e5"}},"timestamp":1778706694,"env":{"SHELL":"/bin/zsh"}} +[0.0, "o", "\u001b[?2026h\u001b[H\u001b[38;2;232;232;232;1m rig.fm - Terminal Radio\u001b[m\r\n\n\u001b[38;2;68;68;68mโ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;232;232;232;1mFilters\u001b[m\u001b[117X\u001b[4;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m \u001b[38;2;232;232;232;1mโ™ฅ Sponsors\u001b[m\u001b[40X\u001b[40C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[125X\u001b[5;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;200;200;200m1. Country:\u001b[m \u001b[38;2;144;144;144mAll Countrys\u001b[m\u001b[99X\u001b[6;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[38;2;102;102;"] +[0.000, "o", "102m Sponsor rig.fm: github.com/sponsors/MWhyte\u001b[m \u001b[6b\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;200;200;200m2. Genre:\u001b[m \u001b[38;2;144;144;144mAll Genres\u001b[m\u001b[103X\u001b[7;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;200;200;200m3. Language:\u001b[m \u001b[38;2;144;144;144mAll Languages\u001b[m\u001b[97X\u001b[8;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;200;200;200m4. Station:\u001b[m \u001b[38;2;144;144;144mAll Stations\u001b[m\u001b[99X\u001b[9;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[mโ†’ \u001b[38;2;200;200;200m5. Favorites:\u001b[m \u001b[38;2;94;158;219;1mโ˜… Only\u001b[m\u001b[103X\u001b[10;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[125X\u001b[11;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[38;2;144;144;144m Press Tab to focus\u001b[m\u001b[105X\u001b[12;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[125X\u001b[13;127H\u001b[38;2;68;68;68m"] +[0.000, "o", "โ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[125X\u001b[14;127H\u001b[38;2;68;68;68mโ”‚โ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;68;68;68mโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ\u001b[m\r\n\u001b[38;2;94;158;219mโ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"] +[0.000, "o", "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ\u001b[38;2;68;68;68mโ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;94;158;219;1mStations (10)\u001b[m\u001b[111X\u001b[17;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;232;232;232;1mPlayer\u001b[m\u001b[44X\u001b[44C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[18;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;144;144;144m10 items\u001b[m\u001b[115X\u001b[19;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;232;232;232;1mDance Wave!\u001b[m\u001b[39X\u001b[39C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[20;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;144;144;144mNo song info\u001b[m\u001b[38X\u001b[38C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mDeep House Radio โ˜…\u001b[m\u001b[105X\u001b[21;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C"] +[0.000, "o", "\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mIreland โ€ข deep house โ€ข MP3 โ€ข 128 kbps โ€ข 8 clicks\u001b[m\u001b[75X\u001b[22;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;144;144;144mHungary\u001b[m\u001b[43X\u001b[43C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[23;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚โ”‚\u001b[m \u001b[38;2;94;158;219mDance Wave! โ˜…\u001b[m\u001b[110X\u001b[24;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;94;158;219mโ–ถ\u001b[m \u001b[38;2;144;144;144mโ–โ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[39X\u001b[39C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚โ”‚\u001b[m \u001b[38;2;144;144;144mHungary โ€ข club dance electronic ... โ€ข MP3 โ€ข 128 kbps โ€ข 243 clicks\u001b[m\u001b[58X\u001b[25;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[26;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;102;102;102mvol \u001b[38;2;94;158;219mโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ\u001b[38;2;6"] +[0.000, "o", "8;68;68mโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘\u001b[38;2;144;144;144m 70%\u001b[m \u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mBloop London Radio (channel 4) - MUSIC ONLY THEMED STREAM. NATURAL HIGH. DRENCHED IN GROOVES, AN ORGANIC JOURNEY OF BEATSโ€ฆ\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;68;68;68mโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\u001b[m \u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe United Kingdom Of Great Britain And Northern Ireland โ€ข electronic,grooves โ€ข MP3 โ€ข 128 kbps โ€ข 3 clicks\u001b[m\u001b[18X\u001b[28;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m \u001b[38;2;102;102;102mMP3 ยท 128 kbps\u001b[m\u001b[36X\u001b[36C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[29;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mQmusic โ˜…\u001b[m\u001b[115X\u001b[30;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b"] +[0.000, "o", "[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe Netherlands โ€ข no tags โ€ข MP3 โ€ข 96 kbps โ€ข 204 clicks\u001b[m\u001b[69X\u001b[31;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[32;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mQ Radio Belfast โ˜…\u001b[m\u001b[106X\u001b[33;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe United Kingdom Of Great Britain And Northern Ireland โ€ข no tags โ€ข MP3 โ€ข 128 kbps โ€ข 5 clicks\u001b[m\u001b[29X\u001b[34;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[35;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mDeep House Radio โ˜…\u001b[m\u001b[105X\u001b[36;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m"] +[0.000, "o", "\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe United States Of America โ€ข electronic โ€ข MP3 โ€ข 128 kbps โ€ข 77 clicks\u001b[m\u001b[53X\u001b[37;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[38;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mlofi girl โ˜…\u001b[m\u001b[112X\u001b[39;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe United States Of America โ€ข no tags โ€ข MP3 โ€ข 0 kbps โ€ข 7 clicks\u001b[m\u001b[59X\u001b[40;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[41;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232mBBC Radio 6 Music โ˜…\u001b[m\u001b[104X\u001b[42;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;102;102;102mThe"] +[0.000, "o", " United Kingdom Of Great Britain And Northern Ireland โ€ข alternative,blues,danc... โ€ข UNKNOWN โ€ข 0 kbps โ€ข 75 clicks\u001b[m \u001b[7b\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[44;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;151;151;151mโ€ข\u001b[38;2;60;60;60mโ€ข\u001b[m\u001b[121X\u001b[45;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[125X\u001b[46;127H\u001b[38;2;94;158;219mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;98;98;98mโ†‘/k\u001b[m \u001b[38;2;74;74;74mup\u001b[38;2;60;60;60m โ€ข \u001b[38;2;98;98;98mโ†“/j\u001b[m \u001b[38;2;74;74;74mdown\u001b[38;2;60;60;60m โ€ข \u001b[38;2;98;98;98m/\u001b[m \u001b[38;2;74;74;74mfilter\u001b[38;2;60;60;60m โ€ข \u001b[38;2;98;98;98mโ†/โ†’\u001b[m \u001b[38;2;74;74;74mpage\u001b[38;2;60;60;60m โ€ข \u001b[38;2;98;98;98mq\u001b[m \u001b[38;2;74;74;74mquit\u001b[38;2;60;60;60m โ€ข \u001b[38;2;98;98;98m?\u001b[m \u001b[38;2;74;74;74mmore\u001b[m\u001b[66X\u001b[47;127H\u001b[38;2;94;158;219"] +[0.000, "o", "mโ”‚\u001b[38;2;68;68;68mโ”‚\u001b[m\u001b[51X\u001b[51C\u001b[38;2;68;68;68mโ”‚\u001b[m\r\n\u001b[38;2;94;158;219mโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ\u001b[38;2;68;68;68mโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ\u001b[m\r\n\n\u001b[38;2;144;144;144mtab: switch sections [Station List] โ€ข โ†‘โ†“/jk: navigate โ€ข enter/space: play โ€ข f: toggle fav โ€ข space: pause โ€ข +/-: volume โ€ข i: identify โ€ข t: sleep timer โ€ข ctrl+t: theme โ€ข ctrl+c: quit\u001b[m\u001b[?2026l"] +[0.296, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.399, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–ƒโ–‚โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.402, "o", "\u001b[?2026h\u001b[7D\u001b[38;2;144;144;144mโ–โ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒ\u001b[m\u001b[?2026l"] +[0.399, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[?2026l"] +[0.266, "o", "\u001b[?2026h\u001b[20;130H\u001b[38;2;94;158;219mTracklist: https://dancewave.online\u001b[m\u001b[22;137H\u001b[38;2;144;144;144m ยท Club Dance Electronic House Trance\u001b[m\u001b[28;144H\u001b[38;2;102;102;102m (actual: 122)\u001b[m\u001b[?2026l"] +[0.135, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.399, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–โ–โ–‚โ–ƒโ–‚โ–โ–‚\u001b[m\u001b[?2026l"] +[0.400, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–โ–โ–‚โ–ƒโ–‚โ–โ–‚โ–ƒ\u001b[m\u001b[?2026l"] +[0.101, "o", "\u001b[?2026h\u001b[24;134H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[?2026l"] +[0.166, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.150, "o", "\u001b[?2026h\u001b[20;130H\u001b[38;2;144;144;144mNo song info\u001b[m\u001b[23X\n\n\u001b[Z\u001b[37X\n\n\u001b[4D\u001b[38;2;144;144;144mโ–โ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒ\u001b[m\u001b[?2026l"] +[0.249, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[?2026l"] +[0.152, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.251, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–โ–โ–‚โ–ƒโ–‚โ–โ–‚\u001b[m\u001b[?2026l"] +[0.147, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–โ–โ–‚โ–ƒโ–‚โ–โ–‚โ–ƒ\u001b[m\u001b[?2026l"] +[0.252, "o", "\u001b[?2026h\u001b[7D\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[?2026l"] +[0.148, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.252, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–ƒโ–‚โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.168, "o", "\u001b[?2026h\u001b[7D\u001b[38;2;144;144;144mโ–โ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒ\u001b[m\u001b[?2026l"] +[0.231, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–‚โ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚\u001b[m\u001b[?2026l"] +[0.168, "o", "\u001b[?2026h\u001b[24;133H\u001b[38;2;144;144;144mโ–ƒโ–‚โ–โ–โ–‚โ–ƒโ–‚โ–\u001b[m\u001b[?2026l"] +[0.151, "o", "\u001b[?2026h\r\u001b[7B\u001b[J\u001b[H\u001b[K\n\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\n\u001b[K\u001b[21;65H\u001b[1K \u001b[38;2;94;158;219mโ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ\u001b[m\u001b[K\u001b[22;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[22;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[23;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;232;232;232;1mโ™ช Identify Track\u001b[m\u001b[35X\u001b[23;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[24;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[24;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[25;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;94;158;219mโ ‡\u001b[m \u001b[38;2;232;232;232mListening...\u001b[m\u001b[35X\u001b[25;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[26;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[26;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[27;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m \u001b[38;2;144;144;144mThis takes about 12-15 seconds. esc to cancel.\u001b[m \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[28;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[28;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[29;65"] +[0.000, "o", "H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[29;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[30;65H\u001b[1K \u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[54X\u001b[30;121H\u001b[38;2;94;158;219mโ”‚\u001b[m\u001b[K\u001b[31;66H\u001b[38;2;94;158;219mโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ\u001b[m\u001b[?2026l"] +[0.081, "o", "\u001b[?2026h\u001b[25;71H\u001b[38;2;94;158;219mโ \u001b[m \u001b[?2026l"] +[0.084, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ‹\u001b[m \u001b[?2026l"] +[0.082, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ™\u001b[m \u001b[?2026l"] +[0.083, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ น\u001b[m \u001b[?2026l"] +[0.083, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ธ\u001b[m \u001b[?2026l"] +[0.083, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ผ\u001b[m \u001b[?2026l"] +[0.084, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ด\u001b[m \u001b[?2026l"] +[0.084, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ฆ\u001b[m \u001b[?2026l"] +[0.085, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ง\u001b[m \u001b[?2026l"] +[0.084, "o", "\u001b[?2026h\b\b\u001b[38;2;94;158;219mโ ‡\u001b[m \u001b[?2026l"] +[0.050, "o", "\u001b[?2026h\b\b\u001b[38;2;232;232;232;1mForever Baby\u001b[m \u001b[26;71H\u001b[38;2;94;158;219mCarlita & Janet Planet\u001b[m\u001b[27;71H\u001b[38;2;144;144;144mSentimental\u001b[38;2;102;102;102m ยท 2024\u001b[m\u001b[28X\u001b[29;71H\u001b[38;2;102;102;102mpress o to open in Shazam ยท enter/esc to close\u001b[m\u001b[?2026l"] +[4.791, "o", "\u001b[>4m\u001b[=0;1u\r\u001b[52d\u001b[?1049l\u001b[?25h\u001b[?2004l\u001b[?1002l\u001b[?1003l\u001b[?1006l\u001b[?2027l"] +[0.003, "o", "\u001b[1m\u001b[7m%\u001b[27m\u001b[1m\u001b[0m \r \r"] +[0.000, "o", "\u001b]2;mrwhyte@MacBookAir:~/Code/GitHub/rig\u0007\u001b]1;..de/GitHub/rig\u0007"] +[0.004, "o", "\u001b]7;file://MacBookAir.lan/Users/mrwhyte/Code/GitHub/rig\u001b\\"] +[0.074, "o", "\r\u001b[0m\u001b[27m\u001b[24m\u001b[J\u001b[1;36mrig\u001b[0m on \u001b[1;35m๎‚  \u001b[0m\u001b[1;35mfeature/shazam-identification\u001b[0m \u001b[1;31m[\u001b[0m\u001b[1;31m$\u001b[0m\u001b[1;31m!\u001b[0m\u001b[1;31m?\u001b[0m\u001b[1;31m]\u001b[0m via \u001b[1;36m๐Ÿน \u001b[0m\u001b[1;36mv1.26.3\u001b[0m\u001b[1;36m \u001b[0mtook \u001b[1;33m29s\u001b[0m \r\n\u001b[1;32mโฏ\u001b[0m \u001b[K"] +[0.000, "o", "\u001b[?1h\u001b="] +[0.000, "o", "\u001b[?2004h"] +[0.159, "o", "\u001b[?2004l\r\r\n"] +[0.039, "x", "0"] diff --git a/docs/assets/identify.gif b/docs/assets/identify.gif new file mode 100644 index 0000000..029a7aa Binary files /dev/null and b/docs/assets/identify.gif differ diff --git a/docs/index.html b/docs/index.html index 5c83502..4420c6e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -119,6 +119,11 @@

Keyboard-driven

Favorites

Save your go-to stations for quick access. Your favorites persist across sessions.

+
+
๐ŸŽค
+

Identify the playing track

+

Press i on a playing station. rig fingerprints the audio locally in pure Go and asks Shazam to identify it. No API key. Press o to open the track on shazam.com.

+
๐ŸŽต

Now playing

@@ -169,6 +174,18 @@

Favourites

Saving and managing favourite radio stations in the terminal
+
+

Identify the playing track

+
+
+
+
+
+ rig +
+ Identifying the playing track Shazam-style from the terminal +
+
diff --git a/go.mod b/go.mod index b49f3de..279d5a1 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,11 @@ require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.0 + github.com/google/uuid v1.6.0 + github.com/hajimehoshi/go-mp3 v0.3.4 github.com/sahilm/fuzzy v0.1.1 + golang.org/x/time v0.15.0 + gonum.org/v1/gonum v0.17.0 ) require ( diff --git a/go.sum b/go.sum index 1a90ac9..45a12e3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= -charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= -charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/bubbletea/v2 v2.0.1 h1:B8e9zzK7x9JJ+XvHGF4xnYu9Xa0E0y0MyggY6dbaCfQ= charm.land/bubbletea/v2 v2.0.1/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= @@ -14,8 +12,6 @@ github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw= github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -32,6 +28,11 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -50,5 +51,10 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= diff --git a/pkg/identifier/capture.go b/pkg/identifier/capture.go new file mode 100644 index 0000000..d35638c --- /dev/null +++ b/pkg/identifier/capture.go @@ -0,0 +1,258 @@ +package identifier + +import ( + "bufio" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" + "time" + + "github.com/hajimehoshi/go-mp3" +) + +// ErrUnsupportedCodec is returned when the stream's Content-Type indicates an +// audio codec other than MP3. Track identification currently only supports +// MP3 streams; AAC and other codecs would need a different decoder. +var ErrUnsupportedCodec = errors.New("unsupported audio codec") + +// streamReadBufBytes is the size of the chunk we read at a time from the +// decoder. go-mp3 emits 16-bit stereo little-endian PCM (4 bytes per frame), +// so 4096 is exactly 1024 frames per read. +const streamReadBufBytes = 4096 + +// CaptureMonoSamples opens streamURL, decodes the next `duration` worth of +// MP3 audio, downmixes to mono and returns the samples normalised to [-1, 1] +// alongside the decoder's native sample rate. +// +// The samples are NOT resampled here; if you intend to feed them to +// shazam.ComputeSignature, run them through Resample first so the rate +// matches what Shazam's matcher expects (FingerprintSampleRate). +func CaptureMonoSamples(ctx context.Context, streamURL string, duration time.Duration) (samples []float64, sampleRate int, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, streamURL, http.NoBody) + if err != nil { + return nil, 0, fmt.Errorf("build request: %w", err) + } + // Ask the server not to interleave ICY metadata into the audio stream โ€” + // most respect this and it keeps the MP3 frame boundaries clean. + req.Header.Set("Icy-MetaData", "0") + req.Header.Set("User-Agent", "rig/identifier") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("open stream: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("stream returned %s", resp.Status) + } + + // Reject obvious non-MP3 codecs up front so the user gets a clear + // "unsupported codec" error instead of a confusing "EOF" from go-mp3. + if ct := resp.Header.Get("Content-Type"); ct != "" && !isMP3ContentType(ct) { + return nil, 0, fmt.Errorf("%w: %s (only MP3 is supported)", ErrUnsupportedCodec, ct) + } + + // Live Shoutcast/Icecast streams hand us bytes mid-frame on connect, + // which makes go-mp3 fail with errors like "is_pos was too big". Wrap + // the body in a reader that scans forward to the first valid MP3 frame + // header before passing data through. + synced, err := newMP3SyncReader(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("sync mp3 stream: %w", err) + } + + dec, err := mp3.NewDecoder(synced) + if err != nil { + // Errors from mp3.NewDecoder almost always mean the stream is not + // an MP3 we can handle โ€” wrong codec (AAC/Ogg), MPEG Layer I/II, + // or unrecognisable bytes after our sync scan. Surface them all as + // ErrUnsupportedCodec so the UI shows a clean message rather than + // a technical decoder error string. + return nil, 0, fmt.Errorf("%w: %s", ErrUnsupportedCodec, err.Error()) + } + + sampleRate = dec.SampleRate() + if sampleRate <= 0 || sampleRate > 192000 { + return nil, 0, fmt.Errorf("implausible stream sample rate: %d Hz", sampleRate) + } + + framesNeeded := int(duration.Seconds() * float64(sampleRate)) + samples = make([]float64, 0, framesNeeded) + + buf := make([]byte, streamReadBufBytes) + for len(samples) < framesNeeded { + n, readErr := dec.Read(buf) + if n > 0 { + n -= n % 4 // align to whole stereo frames + for i := 0; i+3 < n; i += 4 { + left := int16(binary.LittleEndian.Uint16(buf[i : i+2])) //nolint:gosec // G115: PCM frames are intentionally signed + right := int16(binary.LittleEndian.Uint16(buf[i+2 : i+4])) //nolint:gosec // G115: PCM frames are intentionally signed + samples = append(samples, (float64(left)+float64(right))/2/32768) + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + break + } + return nil, 0, fmt.Errorf("read mp3: %w", readErr) + } + } + + if len(samples) > framesNeeded { + samples = samples[:framesNeeded] + } + if len(samples) == 0 { + return nil, 0, errors.New("no audio decoded") + } + + return samples, sampleRate, nil +} + +// FingerprintSampleRate is the rate Shazam's matcher is tuned for. Feeding +// the algorithm at higher rates (e.g. 44100 Hz directly) is technically valid +// per the signature wire format, but produces sparse peaks in Shazam's +// 250-5500 Hz bands and fails to match real-world tracks. Resampling to +// 16 kHz before fingerprinting is what every working Shazam client does. +const FingerprintSampleRate = 16000 + +// Resample converts audio samples from srcRate to dstRate. For downsampling +// it averages over the source window to apply a crude anti-aliasing low-pass +// filter; for upsampling it uses linear interpolation. Quality is adequate +// for audio fingerprinting, where peak topology matters more than fidelity. +// +// If srcRate == dstRate, the input slice is returned unchanged. +func Resample(in []float64, srcRate, dstRate int) []float64 { + if srcRate == dstRate || len(in) == 0 { + return in + } + ratio := float64(srcRate) / float64(dstRate) + outLen := int(float64(len(in)) / ratio) + if outLen <= 0 { + return nil + } + out := make([]float64, outLen) + + if ratio > 1 { + // Downsampling: boxcar-averaged decimation. + window := int(ratio + 0.5) + if window < 2 { + window = 2 + } + for i := range out { + srcStart := int(float64(i) * ratio) + if srcStart >= len(in) { + break + } + srcEnd := srcStart + window + if srcEnd > len(in) { + srcEnd = len(in) + } + sum := 0.0 + for j := srcStart; j < srcEnd; j++ { + sum += in[j] + } + out[i] = sum / float64(srcEnd-srcStart) + } + return out + } + + // Upsampling: linear interpolation. + for i := range out { + srcF := float64(i) * ratio + srcI := int(srcF) + frac := srcF - float64(srcI) + switch { + case srcI+1 < len(in): + out[i] = in[srcI]*(1-frac) + in[srcI+1]*frac + case srcI < len(in): + out[i] = in[srcI] + } + } + return out +} + +// mp3SyncScanLimit is how many bytes we'll scan from the start of a stream +// looking for the first valid MP3 frame header before giving up. +const mp3SyncScanLimit = 1 << 16 // 64 KiB + +// mp3SyncReader wraps an io.Reader and skips past any leading bytes that +// don't begin a valid MP3 frame header. Used to recover from connecting to +// a live Shoutcast/Icecast stream mid-frame. +type mp3SyncReader struct { + br *bufio.Reader +} + +// isMP3ContentType reports whether the HTTP Content-Type advertises an MP3 +// stream. Servers vary in exact spelling (audio/mpeg, audio/mp3, audio/MPA). +func isMP3ContentType(ct string) bool { + mt, _, err := mime.ParseMediaType(ct) + if err != nil { + mt = strings.ToLower(strings.TrimSpace(ct)) + } + switch mt { + case "audio/mpeg", "audio/mp3", "audio/x-mpeg", "audio/mpa", "audio/mpa-robust": + return true + } + return false +} + +// isValidMP3Header returns true if h looks like a real MP3 frame header. +// Loose sync (byte 0 == 0xFF, byte 1 top 3 bits set) gets a lot of false +// positives in ICY metadata and ID3 tags; we additionally reject the +// reserved version, reserved layer, "bad" bitrate index, and reserved +// sample-rate index, which never appear in real audio. +func isValidMP3Header(h []byte) bool { + if len(h) < 3 { + return false + } + if h[0] != 0xFF { + return false + } + if h[1]&0xE0 != 0xE0 { + return false + } + if h[1]&0x18 == 0x08 { // version bits == 01: reserved + return false + } + if h[1]&0x06 == 0x00 { // layer bits == 00: reserved + return false + } + if h[2]&0xF0 == 0xF0 { // bitrate index == 1111: bad + return false + } + if h[2]&0x0C == 0x0C { // sample-rate index == 11: reserved + return false + } + return true +} + +// newMP3SyncReader scans bytes from r until it finds an MP3 frame header +// that passes isValidMP3Header, then returns a reader positioned at that +// byte. Bytes are inspected via Peek so they stay buffered for the decoder. +func newMP3SyncReader(r io.Reader) (*mp3SyncReader, error) { + br := bufio.NewReaderSize(r, 4096) + for scanned := 0; scanned < mp3SyncScanLimit; scanned++ { + head, err := br.Peek(3) + if err != nil { + return nil, fmt.Errorf("peek while seeking sync: %w", err) + } + if isValidMP3Header(head) { + return &mp3SyncReader{br: br}, nil + } + if _, err := br.ReadByte(); err != nil { + return nil, fmt.Errorf("discard while seeking sync: %w", err) + } + } + return nil, fmt.Errorf("no MP3 sync in first %d bytes", mp3SyncScanLimit) +} + +// Read implements io.Reader by delegating to the buffered reader. +func (s *mp3SyncReader) Read(p []byte) (int, error) { + return s.br.Read(p) +} diff --git a/pkg/identifier/identifier.go b/pkg/identifier/identifier.go new file mode 100644 index 0000000..9f335d0 --- /dev/null +++ b/pkg/identifier/identifier.go @@ -0,0 +1,66 @@ +// Package identifier identifies tracks playing on internet radio streams by +// tapping the stream's audio, computing a Shazam fingerprint locally, and +// querying Shazam's recognition endpoint. +package identifier + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/mrwhyte/rig/pkg/identifier/shazam" +) + +// DefaultSampleSeconds is the amount of audio captured for one recognition +// attempt. Shazam typically resolves a track from 8-12 seconds of audio. +const DefaultSampleSeconds = 12 + +// Track is what a successful identification returns to a caller. +type Track struct { + Title string + Artist string + Album string + Year string + ShazamURL string // canonical Shazam track URL, empty if Shazam returned no key + AppleID string // Apple Music track ID, empty if absent +} + +// ErrNoMatch is returned when Shazam responds successfully but cannot identify +// the audio (silence, talk, an unrecognised song). +var ErrNoMatch = errors.New("no shazam match") + +// IdentifyStream captures DefaultSampleSeconds of audio from streamURL, +// fingerprints it, and asks Shazam to identify the track. It returns +// ErrNoMatch if Shazam responds with no matches. +func IdentifyStream(ctx context.Context, streamURL string) (*Track, error) { + return IdentifyStreamFor(ctx, streamURL, DefaultSampleSeconds*time.Second) +} + +// IdentifyStreamFor is the variant of IdentifyStream that lets the caller pick +// the capture duration. Shorter durations are faster but less accurate. +func IdentifyStreamFor(ctx context.Context, streamURL string, duration time.Duration) (*Track, error) { + samples, sampleRate, err := CaptureMonoSamples(ctx, streamURL, duration) + if err != nil { + return nil, fmt.Errorf("capture: %w", err) + } + + resampled := Resample(samples, sampleRate, FingerprintSampleRate) + sig := shazam.ComputeSignature(FingerprintSampleRate, resampled) + result, err := shazam.Identify(ctx, sig) + if err != nil { + return nil, fmt.Errorf("identify: %w", err) + } + if !result.Found { + return nil, ErrNoMatch + } + + return &Track{ + Title: result.Title, + Artist: result.Artist, + Album: result.Album, + Year: result.Year, + ShazamURL: result.ShazamURL(), + AppleID: result.AppleID, + }, nil +} diff --git a/pkg/identifier/shazam/LICENSE b/pkg/identifier/shazam/LICENSE new file mode 100644 index 0000000..0887c7c --- /dev/null +++ b/pkg/identifier/shazam/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2024 Luke Champine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/identifier/shazam/api.go b/pkg/identifier/shazam/api.go new file mode 100644 index 0000000..786227b --- /dev/null +++ b/pkg/identifier/shazam/api.go @@ -0,0 +1,326 @@ +// This file is vendored from https://github.com/lukechampine/barbershop +// Original copyright (c) 2024 Luke Champine, MIT licensed (see LICENSE). +// +// Modifications for rig: +// - Identify now takes a context.Context for cancellation. +// - Result exposes the Shazam track Key so callers can build a Shazam URL. +// - Replaced math/rand with math/rand/v2 for the user-agent picker. +// - Replaced goto-based retry with a bounded loop. +// - Added http.NewRequestWithContext and explicit error handling throughout. + +package shazam + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand/v2" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/time/rate" +) + +// debugEnabled returns true when RIG_SHAZAM_DEBUG=1 in the environment. When +// enabled, postSignature dumps the raw request body and response body to +// stderr so we can diagnose why Shazam is returning no matches. +func debugEnabled() bool { + return os.Getenv("RIG_SHAZAM_DEBUG") == "1" +} + +// Shared rate limiter so we don't hammer Shazam and earn a ban. +var throttle = rate.NewLimiter(rate.Every(3*time.Second), 1) + +// Result contains the outcome of attempting to identify a song. +type Result struct { + Found bool + Artist string + Title string + Album string + Year string + AppleID string + Key string // Shazam track key, used to build https://www.shazam.com/track/ +} + +// ShazamURL returns the canonical Shazam track URL, or an empty string if the +// result does not carry a track key. +func (r Result) ShazamURL() string { + if r.Key == "" { + return "" + } + return "https://www.shazam.com/track/" + r.Key +} + +const ( + endpointHost = "amp.shazam.com" + endpointPath = "/discovery/v5/en/US/android/-/tag" + endpointQuery = "?sync=true&webv3=true&sampling=true&connected=&shazamapiversion=v3&sharehub=true&video=v3" +) + +// Identify sends the given signature to Shazam and returns the recognised +// track, or a Result with Found == false if no match was made. +func Identify(ctx context.Context, sig Signature) (Result, error) { + body, err := buildRequestBody(sig) + if err != nil { + return Result{}, fmt.Errorf("build request body: %w", err) + } + + reqURL := fmt.Sprintf( + "http://%s%s/%s/%s%s", + endpointHost, + endpointPath, + strings.ToUpper(uuid.NewString()), + uuid.NewString(), + endpointQuery, + ) + + respData, err := postSignature(ctx, reqURL, body) + if err != nil { + return Result{}, err + } + if len(respData.Matches) == 0 { + return Result{Found: false}, nil + } + + album, year := "", "" + for _, section := range respData.Track.Sections { + for _, meta := range section.Metadata { + switch meta.Title { + case "Album": + album = meta.Text + case "Released", "Sortie": + year = meta.Text + } + } + } + appleID := "" + for _, action := range respData.Track.Hub.Actions { + if action.Name == "apple" && action.ID != "" { + appleID = action.ID + break + } + } + + return Result{ + Found: true, + Artist: respData.Track.Subtitle, + Title: respData.Track.Title, + Album: album, + Year: year, + AppleID: appleID, + Key: respData.Track.Key, + }, nil +} + +func buildRequestBody(sig Signature) ([]byte, error) { + now := time.Now().UnixMilli() + payload := map[string]any{ + "geolocation": map[string]any{ + "altitude": 300, + "latitude": 45, + "longitude": 2, + }, + "signature": map[string]any{ + "samplems": sig.numSamples / sig.sampleRate * 1000, + "timestamp": now, + "uri": "data:audio/vnd.shazam.sig;base64," + base64.StdEncoding.EncodeToString(sig.encode()), + }, + "timestamp": now, + "timezone": "Europe/Berlin", + } + return json.Marshal(payload) +} + +type shazamResponse struct { + Matches []struct { + ID string + Offset float64 + TimeSkew float64 + FrequencySkew float64 + } + Track struct { + Title string + Subtitle string + Key string + Hub struct { + Actions []struct { + Name string + ID string + } + } + Sections []struct { + Type string + Metadata []struct { + Title string + Text string + } + } + } +} + +const maxRetries = 3 + +func postSignature(ctx context.Context, reqURL string, body []byte) (*shazamResponse, error) { + debug := debugEnabled() + if debug { + fmt.Fprintf(os.Stderr, "[shazam] POST %s\n", reqURL) + fmt.Fprintf(os.Stderr, "[shazam] request body: %s\n", string(body)) + } + + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + if err := throttle.Wait(ctx); err != nil { + return nil, fmt.Errorf("throttle: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + ua := pickUserAgent() + req.Header.Set("User-Agent", ua) + req.Header.Set("Content-Language", "en_US") + req.Header.Set("Content-Type", "application/json") + if debug { + fmt.Fprintf(os.Stderr, "[shazam] User-Agent: %s\n", ua) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + + respBody, readErr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if debug { + fmt.Fprintf(os.Stderr, "[shazam] response status: %s\n", resp.Status) + fmt.Fprintf(os.Stderr, "[shazam] response body (%d bytes): %s\n", len(respBody), string(respBody)) + } + if readErr != nil { + return nil, fmt.Errorf("read response: %w", readErr) + } + + switch resp.StatusCode { + case http.StatusOK: + var out shazamResponse + if err := json.Unmarshal(respBody, &out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &out, nil + case http.StatusTooManyRequests: + lastErr = fmt.Errorf("rate limited by shazam") + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(3 * time.Second): + } + default: + return nil, fmt.Errorf("bad status: %s (%s)", resp.Status, string(respBody)) + } + } + return nil, lastErr +} + +func pickUserAgent() string { + // Picking a UA string is not a security-sensitive decision; math/rand/v2 is fine. + return userAgents[rand.IntN(len(userAgents))] //nolint:gosec // G404: non-crypto use +} + +// userAgents is a pool of Android Dalvik user agents used to look like a +// regular mobile Shazam client. Picked at random per request. +var userAgents = []string{ + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; VS980 4G Build/LRX22G)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-T210 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-P905V Build/LMY47X)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; Vodafone Smart Tab 4G Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SM-G360H Build/KTU84P)", + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; SM-S920L Build/LRX22G)", + "Dalvik/2.1.0 (Linux; U; Android 5.0; Fire Pro Build/LRX21M)", + "Dalvik/2.1.0 (Linux; U; Android 5.0; SM-N9005 Build/LRX21V)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G920F Build/MMB29K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-G7102 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.0; SM-G900F Build/LRX21T)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G928F Build/MMB29K)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-J500FN Build/LMY48B)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; Coolpad 3320A Build/LMY47V)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SM-J110F Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SAMSUNG-SGH-I747 Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SAMSUNG-SM-T337A Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.3; SGH-T999 Build/JSS15J)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; D6603 Build/23.5.A.0.570)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-J700H Build/LMY48B)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; HTC6600LVW Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-N910G Build/LMY47X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-N910T Build/LMY47X)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; C6903 Build/14.4.A.0.157)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.2; GT-I9105P Build/JDQ39)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; GT-I9192 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-G531H Build/LMY48B)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; LGMS345 Build/LMY47V)", + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; HTC One Build/LRX22G)", + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; LG-D800 Build/LRX22G)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SM-T113 Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.2; AndyWin Build/JDQ39E)", + "Dalvik/2.1.0 (Linux; U; Android 5.0; Lenovo A7000-a Build/LRX21M)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; LGL16C Build/KOT49I.L16CV11a)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; GT-I9500 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; SM-A700FD Build/LRX22G)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-G130HN Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-N9005 Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.1.2; LG-E975T Build/JZO54K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; E1 Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; GT-N5100 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-A310F Build/LMY47X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-J105H Build/LMY47V)", + "Dalvik/1.6.0 (Linux; U; Android 4.3; GT-I9305T Build/JSS15J)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; android Build/JDQ39)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.1; HS-U970 Build/JOP40D)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SM-T561 Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.2; GT-P3110 Build/JDQ39)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G925T Build/MMB29K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; HUAWEI Y221-U22 Build/HUAWEIY221-U22)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-G530T1 Build/LMY47X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-G920I Build/LMY47X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; Vodafone Smart ultra 6 Build/LMY47V)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; XT1080 Build/SU6-7.7)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; ASUS MeMO Pad 7 Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-G800F Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; GT-N7100 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G925I Build/MMB29K)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; A0001 Build/MMB29X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1; XT1045 Build/LPB23.13-61)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; LGMS330 Build/LMY47V)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; Z970 Build/KTU84P)", + "Dalvik/2.1.0 (Linux; U; Android 5.0; SM-N900P Build/LRX21V)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; T1-701u Build/HuaweiMediaPad)", + "Dalvik/2.1.0 (Linux; U; Android 5.1; HTCD100LVWPP Build/LMY47O)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G935R4 Build/MMB29M)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G930V Build/MMB29M)", + "Dalvik/2.1.0 (Linux; U; Android 5.0.2; ZTE Blade Q Lux Build/LRX22G)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; GT-I9060I Build/KTU84P)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; LGUS992 Build/MMB29M)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G900P Build/MMB29M)", + "Dalvik/1.6.0 (Linux; U; Android 4.1.2; SGH-T999L Build/JZO54K)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-N910V Build/LMY47X)", + "Dalvik/2.1.0 (Linux; U; Android 5.1.1; SM-P601 Build/LMY47X)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.2; GT-S7272 Build/JDQ39)", + "Dalvik/1.6.0 (Linux; U; Android 4.3; SAMSUNG-SGH-I747 Build/JSS15J)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-G930F Build/MMB29K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; HTC_PO582 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 6.0; HUAWEI MT7-TL10 Build/HuaweiMT7-TL10)", + "Dalvik/2.1.0 (Linux; U; Android 6.0; LG-H811 Build/MRA58K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; SM-N7505 Build/KOT49H)", + "Dalvik/2.1.0 (Linux; U; Android 6.0; LG-H815 Build/MRA58K)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.2; LenovoA3300-HV Build/KOT49H)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SM-G360G Build/KTU84P)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; GT-I9300I Build/KTU84P)", + "Dalvik/2.1.0 (Linux; U; Android 6.0.1; SM-J700T Build/MMB29K)", + "Dalvik/1.6.0 (Linux; U; Android 4.2.2; SM-T217S Build/JDQ39)", + "Dalvik/1.6.0 (Linux; U; Android 4.4.4; SAMSUNG-SM-N900A Build/KTU84P)", +} diff --git a/pkg/identifier/shazam/hanning.go b/pkg/identifier/shazam/hanning.go new file mode 100644 index 0000000..f31b7f3 --- /dev/null +++ b/pkg/identifier/shazam/hanning.go @@ -0,0 +1,2055 @@ +// This file is vendored from https://github.com/lukechampine/barbershop +// Original copyright (c) 2024 Luke Champine, MIT licensed (see LICENSE). + +package shazam + +var hanningMultipliers = [2048]float64{ + 0.0000023508, + 0.0000094032, + 0.000021157, + 0.000037612, + 0.000058769, + 0.000084626, + 0.00011518, + 0.00015044, + 0.0001904, + 0.00023506, + 0.00028442, + 0.00033848, + 0.00039723, + 0.00046069, + 0.00052884, + 0.00060168, + 0.00067923, + 0.00076147, + 0.0008484, + 0.00094003, + 0.0010363, + 0.0011374, + 0.0012431, + 0.0013535, + 0.0014685, + 0.0015883, + 0.0017128, + 0.0018419, + 0.0019757, + 0.0021142, + 0.0022574, + 0.0024053, + 0.0025578, + 0.0027151, + 0.002877, + 0.0030435, + 0.0032148, + 0.0033907, + 0.0035713, + 0.0037566, + 0.0039465, + 0.0041411, + 0.0043403, + 0.0045443, + 0.0047528, + 0.0049661, + 0.0051839, + 0.0054065, + 0.0056337, + 0.0058655, + 0.006102, + 0.0063431, + 0.0065889, + 0.0068393, + 0.0070943, + 0.007354, + 0.0076183, + 0.0078873, + 0.0081608, + 0.008439, + 0.0087219, + 0.0090093, + 0.0093013, + 0.009598, + 0.0098993, + 0.010205, + 0.010516, + 0.010831, + 0.01115, + 0.011475, + 0.011804, + 0.012137, + 0.012475, + 0.012818, + 0.013165, + 0.013517, + 0.013873, + 0.014234, + 0.0146, + 0.01497, + 0.015344, + 0.015724, + 0.016107, + 0.016496, + 0.016889, + 0.017286, + 0.017688, + 0.018094, + 0.018505, + 0.018921, + 0.019341, + 0.019766, + 0.020195, + 0.020628, + 0.021066, + 0.021509, + 0.021956, + 0.022408, + 0.022864, + 0.023324, + 0.023789, + 0.024259, + 0.024733, + 0.025211, + 0.025694, + 0.026182, + 0.026674, + 0.02717, + 0.027671, + 0.028176, + 0.028686, + 0.0292, + 0.029718, + 0.030241, + 0.030768, + 0.0313, + 0.031836, + 0.032377, + 0.032922, + 0.033471, + 0.034025, + 0.034583, + 0.035146, + 0.035712, + 0.036284, + 0.036859, + 0.037439, + 0.038024, + 0.038612, + 0.039205, + 0.039803, + 0.040404, + 0.04101, + 0.04162, + 0.042235, + 0.042854, + 0.043477, + 0.044105, + 0.044736, + 0.045372, + 0.046013, + 0.046657, + 0.047306, + 0.047959, + 0.048617, + 0.049278, + 0.049944, + 0.050614, + 0.051288, + 0.051967, + 0.05265, + 0.053337, + 0.054028, + 0.054723, + 0.055423, + 0.056126, + 0.056834, + 0.057546, + 0.058263, + 0.058983, + 0.059707, + 0.060436, + 0.061169, + 0.061906, + 0.062647, + 0.063392, + 0.064141, + 0.064895, + 0.065652, + 0.066413, + 0.067179, + 0.067949, + 0.068722, + 0.0695, + 0.070282, + 0.071068, + 0.071858, + 0.072652, + 0.07345, + 0.074252, + 0.075058, + 0.075868, + 0.076682, + 0.0775, + 0.078321, + 0.079147, + 0.079977, + 0.080811, + 0.081649, + 0.08249, + 0.083336, + 0.084185, + 0.085039, + 0.085896, + 0.086757, + 0.087622, + 0.088491, + 0.089364, + 0.090241, + 0.091121, + 0.092006, + 0.092894, + 0.093786, + 0.094682, + 0.095582, + 0.096485, + 0.097392, + 0.098303, + 0.099218, + 0.10014, + 0.10106, + 0.10199, + 0.10292, + 0.10385, + 0.10479, + 0.10573, + 0.10667, + 0.10762, + 0.10857, + 0.10953, + 0.11049, + 0.11145, + 0.11242, + 0.11339, + 0.11436, + 0.11534, + 0.11632, + 0.11731, + 0.1183, + 0.11929, + 0.12028, + 0.12128, + 0.12228, + 0.12329, + 0.1243, + 0.12531, + 0.12633, + 0.12735, + 0.12838, + 0.1294, + 0.13043, + 0.13147, + 0.13251, + 0.13355, + 0.13459, + 0.13564, + 0.13669, + 0.13775, + 0.13881, + 0.13987, + 0.14093, + 0.142, + 0.14307, + 0.14415, + 0.14523, + 0.14631, + 0.1474, + 0.14849, + 0.14958, + 0.15067, + 0.15177, + 0.15287, + 0.15398, + 0.15509, + 0.1562, + 0.15731, + 0.15843, + 0.15955, + 0.16068, + 0.1618, + 0.16294, + 0.16407, + 0.16521, + 0.16635, + 0.16749, + 0.16864, + 0.16979, + 0.17094, + 0.1721, + 0.17325, + 0.17442, + 0.17558, + 0.17675, + 0.17792, + 0.1791, + 0.18027, + 0.18145, + 0.18264, + 0.18382, + 0.18501, + 0.1862, + 0.1874, + 0.1886, + 0.1898, + 0.191, + 0.19221, + 0.19342, + 0.19463, + 0.19585, + 0.19707, + 0.19829, + 0.19951, + 0.20074, + 0.20197, + 0.2032, + 0.20444, + 0.20567, + 0.20691, + 0.20816, + 0.2094, + 0.21065, + 0.2119, + 0.21316, + 0.21442, + 0.21568, + 0.21694, + 0.2182, + 0.21947, + 0.22074, + 0.22202, + 0.22329, + 0.22457, + 0.22585, + 0.22713, + 0.22842, + 0.22971, + 0.231, + 0.23229, + 0.23359, + 0.23489, + 0.23619, + 0.23749, + 0.2388, + 0.24011, + 0.24142, + 0.24273, + 0.24405, + 0.24537, + 0.24669, + 0.24801, + 0.24934, + 0.25066, + 0.25199, + 0.25333, + 0.25466, + 0.256, + 0.25734, + 0.25868, + 0.26002, + 0.26137, + 0.26272, + 0.26407, + 0.26542, + 0.26678, + 0.26813, + 0.26949, + 0.27086, + 0.27222, + 0.27359, + 0.27495, + 0.27632, + 0.2777, + 0.27907, + 0.28045, + 0.28183, + 0.28321, + 0.28459, + 0.28597, + 0.28736, + 0.28875, + 0.29014, + 0.29153, + 0.29293, + 0.29432, + 0.29572, + 0.29712, + 0.29852, + 0.29993, + 0.30133, + 0.30274, + 0.30415, + 0.30556, + 0.30698, + 0.30839, + 0.30981, + 0.31123, + 0.31265, + 0.31407, + 0.3155, + 0.31692, + 0.31835, + 0.31978, + 0.32121, + 0.32264, + 0.32408, + 0.32551, + 0.32695, + 0.32839, + 0.32983, + 0.33127, + 0.33272, + 0.33416, + 0.33561, + 0.33706, + 0.33851, + 0.33996, + 0.34141, + 0.34287, + 0.34433, + 0.34578, + 0.34724, + 0.3487, + 0.35017, + 0.35163, + 0.35309, + 0.35456, + 0.35603, + 0.3575, + 0.35897, + 0.36044, + 0.36191, + 0.36339, + 0.36486, + 0.36634, + 0.36782, + 0.3693, + 0.37078, + 0.37226, + 0.37374, + 0.37522, + 0.37671, + 0.3782, + 0.37968, + 0.38117, + 0.38266, + 0.38415, + 0.38565, + 0.38714, + 0.38863, + 0.39013, + 0.39162, + 0.39312, + 0.39462, + 0.39612, + 0.39762, + 0.39912, + 0.40062, + 0.40213, + 0.40363, + 0.40513, + 0.40664, + 0.40815, + 0.40965, + 0.41116, + 0.41267, + 0.41418, + 0.41569, + 0.41721, + 0.41872, + 0.42023, + 0.42174, + 0.42326, + 0.42478, + 0.42629, + 0.42781, + 0.42933, + 0.43084, + 0.43236, + 0.43388, + 0.4354, + 0.43692, + 0.43844, + 0.43997, + 0.44149, + 0.44301, + 0.44453, + 0.44606, + 0.44758, + 0.44911, + 0.45063, + 0.45216, + 0.45369, + 0.45521, + 0.45674, + 0.45827, + 0.4598, + 0.46132, + 0.46285, + 0.46438, + 0.46591, + 0.46744, + 0.46897, + 0.4705, + 0.47203, + 0.47356, + 0.4751, + 0.47663, + 0.47816, + 0.47969, + 0.48122, + 0.48275, + 0.48429, + 0.48582, + 0.48735, + 0.48888, + 0.49042, + 0.49195, + 0.49348, + 0.49502, + 0.49655, + 0.49808, + 0.49962, + 0.50115, + 0.50268, + 0.50422, + 0.50575, + 0.50728, + 0.50882, + 0.51035, + 0.51188, + 0.51341, + 0.51495, + 0.51648, + 0.51801, + 0.51954, + 0.52108, + 0.52261, + 0.52414, + 0.52567, + 0.5272, + 0.52873, + 0.53026, + 0.53179, + 0.53332, + 0.53485, + 0.53638, + 0.53791, + 0.53944, + 0.54097, + 0.5425, + 0.54402, + 0.54555, + 0.54708, + 0.5486, + 0.55013, + 0.55165, + 0.55318, + 0.5547, + 0.55623, + 0.55775, + 0.55927, + 0.5608, + 0.56232, + 0.56384, + 0.56536, + 0.56688, + 0.5684, + 0.56992, + 0.57143, + 0.57295, + 0.57447, + 0.57598, + 0.5775, + 0.57901, + 0.58053, + 0.58204, + 0.58355, + 0.58506, + 0.58657, + 0.58808, + 0.58959, + 0.5911, + 0.59261, + 0.59411, + 0.59562, + 0.59712, + 0.59863, + 0.60013, + 0.60163, + 0.60313, + 0.60463, + 0.60613, + 0.60763, + 0.60912, + 0.61062, + 0.61211, + 0.61361, + 0.6151, + 0.61659, + 0.61808, + 0.61957, + 0.62106, + 0.62255, + 0.62403, + 0.62552, + 0.627, + 0.62848, + 0.62996, + 0.63144, + 0.63292, + 0.6344, + 0.63588, + 0.63735, + 0.63883, + 0.6403, + 0.64177, + 0.64324, + 0.64471, + 0.64617, + 0.64764, + 0.6491, + 0.65057, + 0.65203, + 0.65349, + 0.65495, + 0.6564, + 0.65786, + 0.65931, + 0.66077, + 0.66222, + 0.66367, + 0.66511, + 0.66656, + 0.668, + 0.66945, + 0.67089, + 0.67233, + 0.67377, + 0.67521, + 0.67664, + 0.67807, + 0.67951, + 0.68094, + 0.68236, + 0.68379, + 0.68522, + 0.68664, + 0.68806, + 0.68948, + 0.6909, + 0.69232, + 0.69373, + 0.69514, + 0.69655, + 0.69796, + 0.69937, + 0.70077, + 0.70218, + 0.70358, + 0.70498, + 0.70638, + 0.70777, + 0.70916, + 0.71056, + 0.71195, + 0.71333, + 0.71472, + 0.7161, + 0.71748, + 0.71886, + 0.72024, + 0.72162, + 0.72299, + 0.72436, + 0.72573, + 0.7271, + 0.72846, + 0.72983, + 0.73119, + 0.73254, + 0.7339, + 0.73525, + 0.73661, + 0.73796, + 0.7393, + 0.74065, + 0.74199, + 0.74333, + 0.74467, + 0.74601, + 0.74734, + 0.74867, + 0.75, + 0.75133, + 0.75265, + 0.75397, + 0.75529, + 0.75661, + 0.75792, + 0.75924, + 0.76055, + 0.76185, + 0.76316, + 0.76446, + 0.76576, + 0.76706, + 0.76835, + 0.76965, + 0.77094, + 0.77222, + 0.77351, + 0.77479, + 0.77607, + 0.77735, + 0.77862, + 0.77989, + 0.78116, + 0.78243, + 0.78369, + 0.78495, + 0.78621, + 0.78747, + 0.78872, + 0.78997, + 0.79122, + 0.79246, + 0.79371, + 0.79495, + 0.79618, + 0.79742, + 0.79865, + 0.79988, + 0.8011, + 0.80232, + 0.80354, + 0.80476, + 0.80597, + 0.80719, + 0.80839, + 0.8096, + 0.8108, + 0.812, + 0.8132, + 0.81439, + 0.81558, + 0.81677, + 0.81796, + 0.81914, + 0.82032, + 0.82149, + 0.82266, + 0.82383, + 0.825, + 0.82616, + 0.82732, + 0.82848, + 0.82964, + 0.83079, + 0.83194, + 0.83308, + 0.83422, + 0.83536, + 0.8365, + 0.83763, + 0.83876, + 0.83989, + 0.84101, + 0.84213, + 0.84324, + 0.84436, + 0.84547, + 0.84657, + 0.84768, + 0.84878, + 0.84988, + 0.85097, + 0.85206, + 0.85315, + 0.85423, + 0.85531, + 0.85639, + 0.85746, + 0.85853, + 0.8596, + 0.86066, + 0.86172, + 0.86278, + 0.86383, + 0.86488, + 0.86593, + 0.86697, + 0.86801, + 0.86905, + 0.87008, + 0.87111, + 0.87214, + 0.87316, + 0.87418, + 0.87519, + 0.8762, + 0.87721, + 0.87822, + 0.87922, + 0.88022, + 0.88121, + 0.8822, + 0.88319, + 0.88417, + 0.88515, + 0.88613, + 0.8871, + 0.88807, + 0.88903, + 0.88999, + 0.89095, + 0.8919, + 0.89285, + 0.8938, + 0.89474, + 0.89568, + 0.89662, + 0.89755, + 0.89848, + 0.8994, + 0.90032, + 0.90124, + 0.90215, + 0.90306, + 0.90397, + 0.90487, + 0.90577, + 0.90666, + 0.90755, + 0.90844, + 0.90932, + 0.9102, + 0.91107, + 0.91194, + 0.91281, + 0.91367, + 0.91453, + 0.91539, + 0.91624, + 0.91709, + 0.91793, + 0.91877, + 0.91961, + 0.92044, + 0.92127, + 0.92209, + 0.92291, + 0.92373, + 0.92454, + 0.92535, + 0.92615, + 0.92695, + 0.92775, + 0.92854, + 0.92933, + 0.93011, + 0.93089, + 0.93166, + 0.93244, + 0.9332, + 0.93397, + 0.93473, + 0.93548, + 0.93623, + 0.93698, + 0.93772, + 0.93846, + 0.9392, + 0.93993, + 0.94066, + 0.94138, + 0.9421, + 0.94281, + 0.94352, + 0.94423, + 0.94493, + 0.94563, + 0.94632, + 0.94701, + 0.94769, + 0.94837, + 0.94905, + 0.94972, + 0.95039, + 0.95105, + 0.95171, + 0.95237, + 0.95302, + 0.95367, + 0.95431, + 0.95495, + 0.95558, + 0.95621, + 0.95684, + 0.95746, + 0.95807, + 0.95869, + 0.95929, + 0.9599, + 0.9605, + 0.96109, + 0.96168, + 0.96227, + 0.96285, + 0.96343, + 0.964, + 0.96457, + 0.96514, + 0.9657, + 0.96625, + 0.9668, + 0.96735, + 0.96789, + 0.96843, + 0.96897, + 0.9695, + 0.97002, + 0.97054, + 0.97106, + 0.97157, + 0.97208, + 0.97258, + 0.97308, + 0.97357, + 0.97406, + 0.97455, + 0.97503, + 0.9755, + 0.97598, + 0.97644, + 0.97691, + 0.97736, + 0.97782, + 0.97827, + 0.97871, + 0.97915, + 0.97959, + 0.98002, + 0.98045, + 0.98087, + 0.98129, + 0.9817, + 0.98211, + 0.98251, + 0.98291, + 0.98331, + 0.9837, + 0.98409, + 0.98447, + 0.98484, + 0.98522, + 0.98558, + 0.98595, + 0.98631, + 0.98666, + 0.98701, + 0.98735, + 0.98769, + 0.98803, + 0.98836, + 0.98869, + 0.98901, + 0.98933, + 0.98964, + 0.98995, + 0.99025, + 0.99055, + 0.99085, + 0.99114, + 0.99142, + 0.9917, + 0.99198, + 0.99225, + 0.99251, + 0.99278, + 0.99303, + 0.99329, + 0.99353, + 0.99378, + 0.99402, + 0.99425, + 0.99448, + 0.99471, + 0.99493, + 0.99514, + 0.99535, + 0.99556, + 0.99576, + 0.99596, + 0.99615, + 0.99634, + 0.99652, + 0.9967, + 0.99687, + 0.99704, + 0.9972, + 0.99736, + 0.99752, + 0.99767, + 0.99781, + 0.99796, + 0.99809, + 0.99822, + 0.99835, + 0.99847, + 0.99859, + 0.9987, + 0.99881, + 0.99891, + 0.99901, + 0.99911, + 0.9992, + 0.99928, + 0.99936, + 0.99944, + 0.99951, + 0.99957, + 0.99963, + 0.99969, + 0.99974, + 0.99979, + 0.99983, + 0.99987, + 0.9999, + 0.99993, + 0.99995, + 0.99997, + 0.99999, + 0.99999, + 1.0, + 1.0, + 0.99999, + 0.99999, + 0.99997, + 0.99995, + 0.99993, + 0.9999, + 0.99987, + 0.99983, + 0.99979, + 0.99974, + 0.99969, + 0.99963, + 0.99957, + 0.99951, + 0.99944, + 0.99936, + 0.99928, + 0.9992, + 0.99911, + 0.99901, + 0.99891, + 0.99881, + 0.9987, + 0.99859, + 0.99847, + 0.99835, + 0.99822, + 0.99809, + 0.99796, + 0.99781, + 0.99767, + 0.99752, + 0.99736, + 0.9972, + 0.99704, + 0.99687, + 0.9967, + 0.99652, + 0.99634, + 0.99615, + 0.99596, + 0.99576, + 0.99556, + 0.99535, + 0.99514, + 0.99493, + 0.99471, + 0.99448, + 0.99425, + 0.99402, + 0.99378, + 0.99353, + 0.99329, + 0.99303, + 0.99278, + 0.99251, + 0.99225, + 0.99198, + 0.9917, + 0.99142, + 0.99114, + 0.99085, + 0.99055, + 0.99025, + 0.98995, + 0.98964, + 0.98933, + 0.98901, + 0.98869, + 0.98836, + 0.98803, + 0.98769, + 0.98735, + 0.98701, + 0.98666, + 0.98631, + 0.98595, + 0.98558, + 0.98522, + 0.98484, + 0.98447, + 0.98409, + 0.9837, + 0.98331, + 0.98291, + 0.98251, + 0.98211, + 0.9817, + 0.98129, + 0.98087, + 0.98045, + 0.98002, + 0.97959, + 0.97915, + 0.97871, + 0.97827, + 0.97782, + 0.97736, + 0.97691, + 0.97644, + 0.97598, + 0.9755, + 0.97503, + 0.97455, + 0.97406, + 0.97357, + 0.97308, + 0.97258, + 0.97208, + 0.97157, + 0.97106, + 0.97054, + 0.97002, + 0.9695, + 0.96897, + 0.96843, + 0.96789, + 0.96735, + 0.9668, + 0.96625, + 0.9657, + 0.96514, + 0.96457, + 0.964, + 0.96343, + 0.96285, + 0.96227, + 0.96168, + 0.96109, + 0.9605, + 0.9599, + 0.95929, + 0.95869, + 0.95807, + 0.95746, + 0.95684, + 0.95621, + 0.95558, + 0.95495, + 0.95431, + 0.95367, + 0.95302, + 0.95237, + 0.95171, + 0.95105, + 0.95039, + 0.94972, + 0.94905, + 0.94837, + 0.94769, + 0.94701, + 0.94632, + 0.94563, + 0.94493, + 0.94423, + 0.94352, + 0.94281, + 0.9421, + 0.94138, + 0.94066, + 0.93993, + 0.9392, + 0.93846, + 0.93772, + 0.93698, + 0.93623, + 0.93548, + 0.93473, + 0.93397, + 0.9332, + 0.93244, + 0.93166, + 0.93089, + 0.93011, + 0.92933, + 0.92854, + 0.92775, + 0.92695, + 0.92615, + 0.92535, + 0.92454, + 0.92373, + 0.92291, + 0.92209, + 0.92127, + 0.92044, + 0.91961, + 0.91877, + 0.91793, + 0.91709, + 0.91624, + 0.91539, + 0.91453, + 0.91367, + 0.91281, + 0.91194, + 0.91107, + 0.9102, + 0.90932, + 0.90844, + 0.90755, + 0.90666, + 0.90577, + 0.90487, + 0.90397, + 0.90306, + 0.90215, + 0.90124, + 0.90032, + 0.8994, + 0.89848, + 0.89755, + 0.89662, + 0.89568, + 0.89474, + 0.8938, + 0.89285, + 0.8919, + 0.89095, + 0.88999, + 0.88903, + 0.88807, + 0.8871, + 0.88613, + 0.88515, + 0.88417, + 0.88319, + 0.8822, + 0.88121, + 0.88022, + 0.87922, + 0.87822, + 0.87721, + 0.8762, + 0.87519, + 0.87418, + 0.87316, + 0.87214, + 0.87111, + 0.87008, + 0.86905, + 0.86801, + 0.86697, + 0.86593, + 0.86488, + 0.86383, + 0.86278, + 0.86172, + 0.86066, + 0.8596, + 0.85853, + 0.85746, + 0.85639, + 0.85531, + 0.85423, + 0.85315, + 0.85206, + 0.85097, + 0.84988, + 0.84878, + 0.84768, + 0.84657, + 0.84547, + 0.84436, + 0.84324, + 0.84213, + 0.84101, + 0.83989, + 0.83876, + 0.83763, + 0.8365, + 0.83536, + 0.83422, + 0.83308, + 0.83194, + 0.83079, + 0.82964, + 0.82848, + 0.82732, + 0.82616, + 0.825, + 0.82383, + 0.82266, + 0.82149, + 0.82032, + 0.81914, + 0.81796, + 0.81677, + 0.81558, + 0.81439, + 0.8132, + 0.812, + 0.8108, + 0.8096, + 0.80839, + 0.80719, + 0.80597, + 0.80476, + 0.80354, + 0.80232, + 0.8011, + 0.79988, + 0.79865, + 0.79742, + 0.79618, + 0.79495, + 0.79371, + 0.79246, + 0.79122, + 0.78997, + 0.78872, + 0.78747, + 0.78621, + 0.78495, + 0.78369, + 0.78243, + 0.78116, + 0.77989, + 0.77862, + 0.77735, + 0.77607, + 0.77479, + 0.77351, + 0.77222, + 0.77094, + 0.76965, + 0.76835, + 0.76706, + 0.76576, + 0.76446, + 0.76316, + 0.76185, + 0.76055, + 0.75924, + 0.75792, + 0.75661, + 0.75529, + 0.75397, + 0.75265, + 0.75133, + 0.75, + 0.74867, + 0.74734, + 0.74601, + 0.74467, + 0.74333, + 0.74199, + 0.74065, + 0.7393, + 0.73796, + 0.73661, + 0.73525, + 0.7339, + 0.73254, + 0.73119, + 0.72983, + 0.72846, + 0.7271, + 0.72573, + 0.72436, + 0.72299, + 0.72162, + 0.72024, + 0.71886, + 0.71748, + 0.7161, + 0.71472, + 0.71333, + 0.71195, + 0.71056, + 0.70916, + 0.70777, + 0.70638, + 0.70498, + 0.70358, + 0.70218, + 0.70077, + 0.69937, + 0.69796, + 0.69655, + 0.69514, + 0.69373, + 0.69232, + 0.6909, + 0.68948, + 0.68806, + 0.68664, + 0.68522, + 0.68379, + 0.68236, + 0.68094, + 0.67951, + 0.67807, + 0.67664, + 0.67521, + 0.67377, + 0.67233, + 0.67089, + 0.66945, + 0.668, + 0.66656, + 0.66511, + 0.66367, + 0.66222, + 0.66077, + 0.65931, + 0.65786, + 0.6564, + 0.65495, + 0.65349, + 0.65203, + 0.65057, + 0.6491, + 0.64764, + 0.64617, + 0.64471, + 0.64324, + 0.64177, + 0.6403, + 0.63883, + 0.63735, + 0.63588, + 0.6344, + 0.63292, + 0.63144, + 0.62996, + 0.62848, + 0.627, + 0.62552, + 0.62403, + 0.62255, + 0.62106, + 0.61957, + 0.61808, + 0.61659, + 0.6151, + 0.61361, + 0.61211, + 0.61062, + 0.60912, + 0.60763, + 0.60613, + 0.60463, + 0.60313, + 0.60163, + 0.60013, + 0.59863, + 0.59712, + 0.59562, + 0.59411, + 0.59261, + 0.5911, + 0.58959, + 0.58808, + 0.58657, + 0.58506, + 0.58355, + 0.58204, + 0.58053, + 0.57901, + 0.5775, + 0.57598, + 0.57447, + 0.57295, + 0.57143, + 0.56992, + 0.5684, + 0.56688, + 0.56536, + 0.56384, + 0.56232, + 0.5608, + 0.55927, + 0.55775, + 0.55623, + 0.5547, + 0.55318, + 0.55165, + 0.55013, + 0.5486, + 0.54708, + 0.54555, + 0.54402, + 0.5425, + 0.54097, + 0.53944, + 0.53791, + 0.53638, + 0.53485, + 0.53332, + 0.53179, + 0.53026, + 0.52873, + 0.5272, + 0.52567, + 0.52414, + 0.52261, + 0.52108, + 0.51954, + 0.51801, + 0.51648, + 0.51495, + 0.51341, + 0.51188, + 0.51035, + 0.50882, + 0.50728, + 0.50575, + 0.50422, + 0.50268, + 0.50115, + 0.49962, + 0.49808, + 0.49655, + 0.49502, + 0.49348, + 0.49195, + 0.49042, + 0.48888, + 0.48735, + 0.48582, + 0.48429, + 0.48275, + 0.48122, + 0.47969, + 0.47816, + 0.47663, + 0.4751, + 0.47356, + 0.47203, + 0.4705, + 0.46897, + 0.46744, + 0.46591, + 0.46438, + 0.46285, + 0.46132, + 0.4598, + 0.45827, + 0.45674, + 0.45521, + 0.45369, + 0.45216, + 0.45063, + 0.44911, + 0.44758, + 0.44606, + 0.44453, + 0.44301, + 0.44149, + 0.43997, + 0.43844, + 0.43692, + 0.4354, + 0.43388, + 0.43236, + 0.43084, + 0.42933, + 0.42781, + 0.42629, + 0.42478, + 0.42326, + 0.42174, + 0.42023, + 0.41872, + 0.41721, + 0.41569, + 0.41418, + 0.41267, + 0.41116, + 0.40965, + 0.40815, + 0.40664, + 0.40513, + 0.40363, + 0.40213, + 0.40062, + 0.39912, + 0.39762, + 0.39612, + 0.39462, + 0.39312, + 0.39162, + 0.39013, + 0.38863, + 0.38714, + 0.38565, + 0.38415, + 0.38266, + 0.38117, + 0.37968, + 0.3782, + 0.37671, + 0.37522, + 0.37374, + 0.37226, + 0.37078, + 0.3693, + 0.36782, + 0.36634, + 0.36486, + 0.36339, + 0.36191, + 0.36044, + 0.35897, + 0.3575, + 0.35603, + 0.35456, + 0.35309, + 0.35163, + 0.35017, + 0.3487, + 0.34724, + 0.34578, + 0.34433, + 0.34287, + 0.34141, + 0.33996, + 0.33851, + 0.33706, + 0.33561, + 0.33416, + 0.33272, + 0.33127, + 0.32983, + 0.32839, + 0.32695, + 0.32551, + 0.32408, + 0.32264, + 0.32121, + 0.31978, + 0.31835, + 0.31692, + 0.3155, + 0.31407, + 0.31265, + 0.31123, + 0.30981, + 0.30839, + 0.30698, + 0.30556, + 0.30415, + 0.30274, + 0.30133, + 0.29993, + 0.29852, + 0.29712, + 0.29572, + 0.29432, + 0.29293, + 0.29153, + 0.29014, + 0.28875, + 0.28736, + 0.28597, + 0.28459, + 0.28321, + 0.28183, + 0.28045, + 0.27907, + 0.2777, + 0.27632, + 0.27495, + 0.27359, + 0.27222, + 0.27086, + 0.26949, + 0.26813, + 0.26678, + 0.26542, + 0.26407, + 0.26272, + 0.26137, + 0.26002, + 0.25868, + 0.25734, + 0.256, + 0.25466, + 0.25333, + 0.25199, + 0.25066, + 0.24934, + 0.24801, + 0.24669, + 0.24537, + 0.24405, + 0.24273, + 0.24142, + 0.24011, + 0.2388, + 0.23749, + 0.23619, + 0.23489, + 0.23359, + 0.23229, + 0.231, + 0.22971, + 0.22842, + 0.22713, + 0.22585, + 0.22457, + 0.22329, + 0.22202, + 0.22074, + 0.21947, + 0.2182, + 0.21694, + 0.21568, + 0.21442, + 0.21316, + 0.2119, + 0.21065, + 0.2094, + 0.20816, + 0.20691, + 0.20567, + 0.20444, + 0.2032, + 0.20197, + 0.20074, + 0.19951, + 0.19829, + 0.19707, + 0.19585, + 0.19463, + 0.19342, + 0.19221, + 0.191, + 0.1898, + 0.1886, + 0.1874, + 0.1862, + 0.18501, + 0.18382, + 0.18264, + 0.18145, + 0.18027, + 0.1791, + 0.17792, + 0.17675, + 0.17558, + 0.17442, + 0.17325, + 0.1721, + 0.17094, + 0.16979, + 0.16864, + 0.16749, + 0.16635, + 0.16521, + 0.16407, + 0.16294, + 0.1618, + 0.16068, + 0.15955, + 0.15843, + 0.15731, + 0.1562, + 0.15509, + 0.15398, + 0.15287, + 0.15177, + 0.15067, + 0.14958, + 0.14849, + 0.1474, + 0.14631, + 0.14523, + 0.14415, + 0.14307, + 0.142, + 0.14093, + 0.13987, + 0.13881, + 0.13775, + 0.13669, + 0.13564, + 0.13459, + 0.13355, + 0.13251, + 0.13147, + 0.13043, + 0.1294, + 0.12838, + 0.12735, + 0.12633, + 0.12531, + 0.1243, + 0.12329, + 0.12228, + 0.12128, + 0.12028, + 0.11929, + 0.1183, + 0.11731, + 0.11632, + 0.11534, + 0.11436, + 0.11339, + 0.11242, + 0.11145, + 0.11049, + 0.10953, + 0.10857, + 0.10762, + 0.10667, + 0.10573, + 0.10479, + 0.10385, + 0.10292, + 0.10199, + 0.10106, + 0.10014, + 0.099218, + 0.098303, + 0.097392, + 0.096485, + 0.095582, + 0.094682, + 0.093786, + 0.092894, + 0.092006, + 0.091121, + 0.090241, + 0.089364, + 0.088491, + 0.087622, + 0.086757, + 0.085896, + 0.085039, + 0.084185, + 0.083336, + 0.08249, + 0.081649, + 0.080811, + 0.079977, + 0.079147, + 0.078321, + 0.0775, + 0.076682, + 0.075868, + 0.075058, + 0.074252, + 0.07345, + 0.072652, + 0.071858, + 0.071068, + 0.070282, + 0.0695, + 0.068722, + 0.067949, + 0.067179, + 0.066413, + 0.065652, + 0.064895, + 0.064141, + 0.063392, + 0.062647, + 0.061906, + 0.061169, + 0.060436, + 0.059707, + 0.058983, + 0.058263, + 0.057546, + 0.056834, + 0.056126, + 0.055423, + 0.054723, + 0.054028, + 0.053337, + 0.05265, + 0.051967, + 0.051288, + 0.050614, + 0.049944, + 0.049278, + 0.048617, + 0.047959, + 0.047306, + 0.046657, + 0.046013, + 0.045372, + 0.044736, + 0.044105, + 0.043477, + 0.042854, + 0.042235, + 0.04162, + 0.04101, + 0.040404, + 0.039803, + 0.039205, + 0.038612, + 0.038024, + 0.037439, + 0.036859, + 0.036284, + 0.035712, + 0.035146, + 0.034583, + 0.034025, + 0.033471, + 0.032922, + 0.032377, + 0.031836, + 0.0313, + 0.030768, + 0.030241, + 0.029718, + 0.0292, + 0.028686, + 0.028176, + 0.027671, + 0.02717, + 0.026674, + 0.026182, + 0.025694, + 0.025211, + 0.024733, + 0.024259, + 0.023789, + 0.023324, + 0.022864, + 0.022408, + 0.021956, + 0.021509, + 0.021066, + 0.020628, + 0.020195, + 0.019766, + 0.019341, + 0.018921, + 0.018505, + 0.018094, + 0.017688, + 0.017286, + 0.016889, + 0.016496, + 0.016107, + 0.015724, + 0.015344, + 0.01497, + 0.0146, + 0.014234, + 0.013873, + 0.013517, + 0.013165, + 0.012818, + 0.012475, + 0.012137, + 0.011804, + 0.011475, + 0.01115, + 0.010831, + 0.010516, + 0.010205, + 0.0098993, + 0.009598, + 0.0093013, + 0.0090093, + 0.0087219, + 0.008439, + 0.0081608, + 0.0078873, + 0.0076183, + 0.007354, + 0.0070943, + 0.0068393, + 0.0065889, + 0.0063431, + 0.006102, + 0.0058655, + 0.0056337, + 0.0054065, + 0.0051839, + 0.0049661, + 0.0047528, + 0.0045443, + 0.0043403, + 0.0041411, + 0.0039465, + 0.0037566, + 0.0035713, + 0.0033907, + 0.0032148, + 0.0030435, + 0.002877, + 0.0027151, + 0.0025578, + 0.0024053, + 0.0022574, + 0.0021142, + 0.0019757, + 0.0018419, + 0.0017128, + 0.0015883, + 0.0014685, + 0.0013535, + 0.0012431, + 0.0011374, + 0.0010363, + 0.00094003, + 0.0008484, + 0.00076147, + 0.00067923, + 0.00060168, + 0.00052884, + 0.00046069, + 0.00039723, + 0.00033848, + 0.00028442, + 0.00023506, + 0.0001904, + 0.00015044, + 0.00011518, + 0.000084626, + 0.000058769, + 0.000037612, + 0.000021157, + 0.0000094032, + 0.0000023508, +} diff --git a/pkg/identifier/shazam/signature.go b/pkg/identifier/shazam/signature.go new file mode 100644 index 0000000..d6df60a --- /dev/null +++ b/pkg/identifier/shazam/signature.go @@ -0,0 +1,242 @@ +// This file is vendored from https://github.com/lukechampine/barbershop +// Original copyright (c) 2024 Luke Champine, MIT licensed (see LICENSE). +// +// Modifications for rig: +// - Dropped the optional CollectSample helper that depended on faiface/beep. +// - Dropped the unused Signature.decode round-trip method. +// - Added explicit error handling on binary writes where required by linters. + +package shazam + +import ( + "bytes" + "encoding/binary" + "hash/crc32" + "math" + + "gonum.org/v1/gonum/dsp/fourier" +) + +func convertSampleRate(x int) int { + return map[int]int{ + 1: 8000, + 2: 11025, + 3: 16000, + 4: 32000, + 5: 44100, + + 8000: 1, + 11025: 2, + 16000: 3, + 32000: 4, + 44100: 5, + }[x] +} + +type frequencyPeak struct { + pass int + magnitude int + bin int +} + +// A Signature is a unique fingerprint of an audio sample. +type Signature struct { + sampleRate int + numSamples int + peaksByBand [5][]frequencyPeak +} + +// encode serialises the signature into Shazam's wire format. +// Writes to bytes.Buffer never fail, so binary.Write errors are intentionally +// ignored. Integer truncations on this path are deliberate bit packing for +// Shazam's wire format, so gosec G115 is suppressed throughout. +// +//nolint:gosec // G115: intentional bit packing for Shazam wire format +func (s Signature) encode() []byte { + var buf []byte + write := func(u uint32) { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], u) + buf = append(buf, b[:]...) + } + + // header + write(0xcafe2580) + write(0) // checksum + write(0) // length + write(0x94119c00) + write(0) + write(0) + write(0) + write(uint32(convertSampleRate(s.sampleRate)) << 27) + write(0) + write(0) + write(uint32(s.numSamples) + uint32(float64(s.sampleRate)*0.24)) + write(0x007c0000) + write(uint32(0x40000000)) + write(0) // length2 + + // peaks + for band, peaks := range s.peaksByBand { + if len(peaks) == 0 { + continue + } + var peakBuf bytes.Buffer + pass := 0 + for _, peak := range peaks { + if peak.pass-pass >= 255 { + peakBuf.WriteByte(0xFF) + _ = binary.Write(&peakBuf, binary.LittleEndian, uint32(peak.pass)) + pass = peak.pass + } + _ = binary.Write(&peakBuf, binary.LittleEndian, uint8(peak.pass-pass)) + _ = binary.Write(&peakBuf, binary.LittleEndian, uint16(peak.magnitude)) + _ = binary.Write(&peakBuf, binary.LittleEndian, uint16(peak.bin)) + pass = peak.pass + } + write(uint32(0x60030040 + band)) + write(uint32(peakBuf.Len())) + for peakBuf.Len()%4 != 0 { + peakBuf.WriteByte(0x00) + } + buf = append(buf, peakBuf.Bytes()...) + } + + binary.LittleEndian.PutUint32(buf[8:12], uint32(len(buf[48:]))) + binary.LittleEndian.PutUint32(buf[52:56], uint32(len(buf[48:]))) + binary.LittleEndian.PutUint32(buf[4:8], crc32.ChecksumIEEE(buf[8:])) + return buf +} + +type ring[T any] struct { + buf []T + index int +} + +func (r ring[T]) mod(i int) int { + for i < 0 { + i += len(r.buf) + } + return i % len(r.buf) +} + +func (r ring[T]) At(i int) *T { + return &r.buf[r.mod(r.index+i)] +} + +func (r ring[T]) Append(x ...T) ring[T] { + for len(x) > 0 { + n := copy(r.buf[r.index:], x) + x = x[n:] + r.index = (r.index + n) % len(r.buf) + } + return r +} + +func (r ring[T]) Slice(s []T, offset int) { + offset = r.mod(offset + r.index) + for len(s) > 0 { + n := copy(s, r.buf[offset:]) + s = s[n:] + offset = (offset + n) % len(r.buf) + } +} + +func newRing[T any](size int) ring[T] { + return ring[T]{buf: make([]T, size)} +} + +// ComputeSignature computes the audio signature of the provided mono samples +// at the given sample rate. The sample rate must be one of 8000, 11025, 16000, +// 32000, or 44100 Hz. +func ComputeSignature(sampleRate int, samples []float64) Signature { + maxNeighbor := func(spreadOutputs ring[[1025]float64], i int) (neighbor float64) { + for _, off := range []int{-10, -7, -4, -3, 1, 2, 5, 8} { + neighbor = max(neighbor, spreadOutputs.At(-49)[(i+off)]) + } + for _, off := range []int{-53, -45, 165, 172, 179, 186, 193, 200, 214, 221, 228, 235, 242, 249} { + neighbor = max(neighbor, spreadOutputs.At(off)[i-1]) + } + return neighbor + } + normalizePeak := func(x float64) float64 { + return math.Log(max(x, 1.0/64))*1477.3 + 6144 + } + peakBand := func(bin int) (int, bool) { + hz := (bin * sampleRate) / (2 * 1024 * 64) + band, ok := map[bool]int{ + 250 <= hz && hz < 520: 0, + 520 <= hz && hz < 1450: 1, + 1450 <= hz && hz < 3500: 2, + 3500 <= hz && hz <= 5500: 3, + }[true] + return band, ok + } + + fft := fourier.NewFFT(2048) + samplesRing := newRing[float64](2048) + fftOutputs := newRing[[1025]float64](256) + spreadOutputs := newRing[[1025]float64](256) + var peaksByBand [5][]frequencyPeak + for i := 0; i*128+128 < len(samples); i++ { + samplesRing = samplesRing.Append(samples[i*128:][:128]...) + + // Perform FFT. + reorderedSamples := make([]float64, 2048) + samplesRing.Slice(reorderedSamples, 0) + for j, m := range &hanningMultipliers { + reorderedSamples[j] = math.Round(reorderedSamples[j]*1024*64) * m + } + var outputs [1025]float64 + for k, c := range fft.Coefficients(nil, reorderedSamples) { + outputs[k] = max((real(c)*real(c)+imag(c)*imag(c))/(1<<17), 0.0000000001) + } + fftOutputs = fftOutputs.Append(outputs) + + // Spread peaks, both in the frequency domain... + for j := 0; j < len(outputs)-2; j++ { + outputs[j] = max(outputs[j], outputs[j+1], outputs[j+2]) + } + spreadOutputs = spreadOutputs.Append(outputs) + // ... and in the time domain. + for _, off := range []int{-2, -4, -7} { + prev := spreadOutputs.At(off) + for j := range prev { + prev[j] = max(prev[j], outputs[j]) + } + } + + // Accumulate samples until we have enough... + if i < 45 { + continue + } + // ...then recognise peaks. + fftOutput := fftOutputs.At(-46) + for bin := 10; bin < 1015; bin++ { + // Ensure that this is a frequency- and time-domain local maximum. + if fftOutput[bin] <= maxNeighbor(spreadOutputs, bin) { + continue + } + // Normalise and compute frequency band. + before := normalizePeak(fftOutput[bin-1]) + peak := normalizePeak(fftOutput[bin]) + after := normalizePeak(fftOutput[bin+1]) + variation := int((32 * (after - before)) / (2*peak - after - before)) + peakBin := bin*64 + variation + band, ok := peakBand(peakBin) + if !ok { + continue + } + peaksByBand[band] = append(peaksByBand[band], frequencyPeak{ + pass: i - 45, + magnitude: int(peak), + bin: peakBin, + }) + } + } + return Signature{ + sampleRate: sampleRate, + numSamples: len(samples), + peaksByBand: peaksByBand, + } +} diff --git a/pkg/identifier/shazam/signature_test.go b/pkg/identifier/shazam/signature_test.go new file mode 100644 index 0000000..c28fc8d --- /dev/null +++ b/pkg/identifier/shazam/signature_test.go @@ -0,0 +1,50 @@ +// This file is vendored from https://github.com/lukechampine/barbershop +// Original copyright (c) 2024 Luke Champine, MIT licensed (see LICENSE). + +package shazam + +import ( + "crypto/sha256" + "fmt" + "math" + "testing" +) + +func TestSignature(t *testing.T) { + samples := make([]float64, 128) + sig := ComputeSignature(16000, samples) + h := sha256.Sum256(sig.encode()) + if fmt.Sprintf("%x", h) != "4ae7d1ae7a4787a7d6cda559db6e17026f60369b3485b762759b7a07ff24fab9" { + t.Fatalf("bad signature: %x", h) + } + + samples = make([]float64, 1024) + for i := range samples { + samples[i] = float64(i) + } + sig = ComputeSignature(16000, samples) + h = sha256.Sum256(sig.encode()) + if fmt.Sprintf("%x", h) != "073022772a4bc617a855adfb6265316f23ae6a25045e670e0904a2b11f132a75" { + t.Fatalf("bad signature: %x", h) + } + + samples = make([]float64, 16*1024) + for i := range samples { + samples[i] = math.Sin(float64(i) * 2 * math.Pi / 256) + } + sig = ComputeSignature(16000, samples) + h = sha256.Sum256(sig.encode()) + if fmt.Sprintf("%x", h) != "c8c055411ec845f6d57b27baf7fc5735fdaf51f2a6026dd12f09d0eb17652c02" { + t.Fatalf("bad signature: %x", h) + } + + samples = make([]float64, 7*1024+55) + for i := range samples { + samples[i] = math.Cos(float64(i+12) * math.E) + } + sig = ComputeSignature(16000, samples) + h = sha256.Sum256(sig.encode()) + if fmt.Sprintf("%x", h) != "e399475137268c73d7e6665479358370c0979f7d2c3860f71b1e035105b3a1d8" { + t.Fatalf("bad signature: %x", h) + } +} diff --git a/pkg/player/player.go b/pkg/player/player.go index fa0e3d7..90a526d 100644 --- a/pkg/player/player.go +++ b/pkg/player/player.go @@ -14,6 +14,12 @@ import ( "time" ) +// MPV IPC protocol literals. +const ( + mpvCmdKey = "command" + mpvCmdSetProperty = "set_property" +) + // State represents the current player state. type State int @@ -147,7 +153,7 @@ func (p *MPVPlayer) Pause() error { } if err := p.sendCommand(map[string]interface{}{ - "command": []interface{}{"set_property", "pause", true}, + mpvCmdKey: []interface{}{mpvCmdSetProperty, "pause", true}, }); err != nil { return err } @@ -166,7 +172,7 @@ func (p *MPVPlayer) Resume() error { } if err := p.sendCommand(map[string]interface{}{ - "command": []interface{}{"set_property", "pause", false}, + mpvCmdKey: []interface{}{mpvCmdSetProperty, "pause", false}, }); err != nil { return err } @@ -192,7 +198,7 @@ func (p *MPVPlayer) stopLocked() { // Send quit command _ = p.sendCommand(map[string]interface{}{ - "command": []interface{}{"quit"}, + mpvCmdKey: []interface{}{"quit"}, }) // Kill process if still running @@ -224,7 +230,7 @@ func (p *MPVPlayer) SetVolume(volume int) error { if p.state == StatePlaying || p.state == StatePaused { if err := p.sendCommand(map[string]interface{}{ - "command": []interface{}{"set_property", "volume", volume}, + mpvCmdKey: []interface{}{mpvCmdSetProperty, "volume", volume}, }); err != nil { return err } @@ -317,7 +323,7 @@ func (p *MPVPlayer) getProperty(property string) (interface{}, error) { // Send command cmd := map[string]interface{}{ - "command": []interface{}{"get_property", property}, + mpvCmdKey: []interface{}{"get_property", property}, "request_id": 1, } encoder := json.NewEncoder(conn) diff --git a/pkg/ui/identify.go b/pkg/ui/identify.go new file mode 100644 index 0000000..2c99b5b --- /dev/null +++ b/pkg/ui/identify.go @@ -0,0 +1,214 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/mrwhyte/rig/pkg/identifier" +) + +// identifyDuration is how much audio we capture for one recognition attempt. +// Long enough for reliable matches, short enough not to bore the user. +const identifyDuration = 12 * time.Second + +// identifyOverallTimeout caps the full operation including network latency +// and the Shazam rate limiter window. +const identifyOverallTimeout = 45 * time.Second + +// identifyResultMsg carries the result of an identification attempt. +type identifyResultMsg struct { + track *identifier.Track + err error +} + +// isIdentifying reports whether the identify modal is in its loading state, +// i.e. an identification is in flight and we haven't received a result yet. +func (m *Model) isIdentifying() bool { + return m.showIdentifyModal && m.identifyTrack == nil && m.identifyErr == nil +} + +// startIdentify launches the async identification command. The capture, +// fingerprint, and Shazam round-trip all happen inside the returned tea.Cmd +// so the UI stays responsive. The created context is stored on the model so +// Esc/Enter can abort the in-flight goroutine instead of leaking it. +func (m *Model) startIdentify() tea.Cmd { + if m.playing == nil { + return nil + } + streamURL := m.playing.URLResolved + ctx, cancel := context.WithTimeout(context.Background(), identifyOverallTimeout) + m.identifyCancel = cancel + return func() tea.Msg { + track, err := identifier.IdentifyStreamFor(ctx, streamURL, identifyDuration) + return identifyResultMsg{track: track, err: err} + } +} + +// openURL opens rawURL in the user's default browser. The scheme is +// validated to be http/https before exec'ing anything. +func openURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("unsupported url scheme: %q", parsed.Scheme) + } + + ctx := context.Background() + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(ctx, "open", rawURL) //nolint:gosec // G204: URL scheme validated above + case "linux": + cmd = exec.CommandContext(ctx, "xdg-open", rawURL) //nolint:gosec // G204: URL scheme validated above + case "windows": + cmd = exec.CommandContext(ctx, "cmd", "/c", "start", rawURL) //nolint:gosec // G204: URL scheme validated above + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } + return cmd.Start() +} + +// handleIdentifyModalInput handles keyboard input while the identify modal +// is showing โ€” whether the spinner is running or a result is on screen. +func (m *Model) handleIdentifyModalInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case keyCtrlC: + m.stopPlayback() + return m, tea.Quit + + case keyEsc, keyEnter: + m.resetIdentifyState() + return m, nil + + case keyOpen: + if m.identifyTrack != nil && m.identifyTrack.ShazamURL != "" { + if err := openURL(m.identifyTrack.ShazamURL); err != nil { + m.identifyErr = fmt.Errorf("open url: %w", err) + } + } + return m, nil + } + return m, nil +} + +// resetIdentifyState clears identify-related state and cancels any in-flight +// goroutine. The cancellation propagates to the HTTP request, the MP3 +// decoder, and the Shazam rate limiter, so dismissed identifications stop +// consuming network and memory immediately. +func (m *Model) resetIdentifyState() { + if m.identifyCancel != nil { + m.identifyCancel() + m.identifyCancel = nil + } + m.showIdentifyModal = false + m.identifyTrack = nil + m.identifyErr = nil +} + +// renderIdentifyModal renders a centred modal showing either the spinner +// (while identifying) or the result (after completion). +func (m *Model) renderIdentifyModal() string { + const modalWidth = 56 + const modalHeight = 11 + + title := lipgloss.NewStyle(). + Bold(true). + Foreground(colorTitle). + Padding(0, 1). + Render("โ™ช Identify Track") + + var content string + switch { + case m.isIdentifying(): + content = m.renderIdentifySpinner() + case m.identifyErr != nil: + content = m.renderIdentifyError() + case m.identifyTrack != nil: + content = m.renderIdentifyResult() + default: + content = "\n (nothing to display)\n" + } + + panel := lipgloss.JoinVertical(lipgloss.Left, title, content) + + modal := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorAccent). + Padding(1, 2). + Width(modalWidth). + Height(modalHeight). + Render(panel) + + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal) +} + +func (m *Model) renderIdentifySpinner() string { + return fmt.Sprintf( + "\n %s %s\n\n %s\n", + lipgloss.NewStyle().Foreground(colorAccent).Render(m.identifySpinner.View()), + lipgloss.NewStyle().Foreground(colorTitle).Render("Listening..."), + lipgloss.NewStyle(). + Foreground(colorMuted). + Render("This takes about 12-15 seconds. esc to cancel."), + ) +} + +func (m *Model) renderIdentifyError() string { + // For codec mismatches we deliberately hide the technical decoder + // string ("only layer3 ... want 1 got 3" etc) and just show the + // friendly headline. + if errors.Is(m.identifyErr, identifier.ErrUnsupportedCodec) { + return fmt.Sprintf( + "\n %s\n\n %s", + lipgloss.NewStyle().Foreground(colorWarning).Render("Sorry, only MP3 streams are currently supported"), + lipgloss.NewStyle().Foreground(colorDim).Render("enter/esc to close"), + ) + } + + headline := "Couldn't identify the track" + if errors.Is(m.identifyErr, identifier.ErrNoMatch) { + headline = "No match found" + } + return fmt.Sprintf( + "\n %s\n\n %s\n\n %s", + lipgloss.NewStyle().Foreground(colorWarning).Render(headline), + lipgloss.NewStyle().Foreground(colorMuted).Render(m.identifyErr.Error()), + lipgloss.NewStyle().Foreground(colorDim).Render("enter/esc to close"), + ) +} + +func (m *Model) renderIdentifyResult() string { + t := m.identifyTrack + var b strings.Builder + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorTitle).Render(t.Title)) + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Foreground(colorAccent).Render(t.Artist)) + if t.Album != "" { + b.WriteString("\n ") + b.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Render(t.Album)) + if t.Year != "" { + b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render(" ยท " + t.Year)) + } + } + b.WriteString("\n\n ") + if t.ShazamURL != "" { + b.WriteString(lipgloss.NewStyle(). + Foreground(colorDim). + Render("press o to open in Shazam ยท enter/esc to close")) + } else { + b.WriteString(lipgloss.NewStyle().Foreground(colorDim).Render("enter/esc to close")) + } + return b.String() +} diff --git a/pkg/ui/keys.go b/pkg/ui/keys.go index 4aa5d09..61a068c 100644 --- a/pkg/ui/keys.go +++ b/pkg/ui/keys.go @@ -18,6 +18,7 @@ const ( keyCtrlC = "ctrl+c" keyEnter = "enter" keyEsc = "esc" + keyOpen = "o" ) // handleKeyPress handles keyboard input. @@ -31,6 +32,11 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.handleTimerModalInput(msg) } + // If the identify modal is open, route keys through it. + if m.showIdentifyModal { + return m.handleIdentifyModalInput(msg) + } + // If editing a filter, handle input differently if m.editingFilter != FilterNone { return m.handleFilterInput(msg) @@ -62,47 +68,7 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil case "space": - // Toggle play/pause, or play selected station - switch { - case m.isPlaying && m.playing != nil: - // Currently playing - pause it - if err := m.player.Pause(); err == nil { - m.isPlaying = false - if m.sleepTimerActive { - m.sleepTimerPaused = true - elapsed := time.Since(m.sleepTimerStart) - m.sleepTimerRemaining = m.sleepTimerDuration - elapsed - } - } - return m, nil - case !m.isPlaying && m.playing != nil: - // Paused - resume it - if err := m.player.Resume(); err == nil { - m.isPlaying = true - if m.sleepTimerActive && m.sleepTimerPaused { - m.sleepTimerPaused = false - m.sleepTimerDuration = m.sleepTimerRemaining - m.sleepTimerStart = time.Now() - waveCmd := m.waveTick() - sleepCmd := m.sleepTimerTick() - return m, tea.Batch(waveCmd, sleepCmd) - } - cmd := m.waveTick() - return m, cmd - } - return m, nil - case m.focusedSection == SectionStationList && len(m.stations) > 0: - // No station playing and in station list - play selected station - // Get the actual selected item from the filtered list - if item := m.stationList.SelectedItem(); item != nil { - if stationItem, ok := item.(StationItem); ok { - return m, func() tea.Msg { - return playStationMsg{&stationItem.station} - } - } - } - } - return m, nil + return m.handleSpaceKey() case "s": // Stop playback @@ -144,6 +110,15 @@ func (m *Model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } return m, m.timerInput.Focus() + case "i": + // Identify the currently playing track via Shazam. + if m.playing == nil { + return m, nil + } + m.resetIdentifyState() + m.showIdentifyModal = true + return m, tea.Batch(m.startIdentify(), m.identifySpinner.Tick) + case "f": // Toggle favorite for selected station if m.focusedSection == SectionStationList && len(m.stations) > 0 { @@ -578,3 +553,49 @@ func (m *Model) handleThemeModalInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m, nil } + +// handleSpaceKey handles the space-bar shortcut: toggle play/pause when a +// station is loaded, or play the selected station when nothing is loaded. +func (m *Model) handleSpaceKey() (tea.Model, tea.Cmd) { + switch { + case m.isPlaying && m.playing != nil: + // Currently playing - pause it. + if err := m.player.Pause(); err == nil { + m.isPlaying = false + if m.sleepTimerActive { + m.sleepTimerPaused = true + elapsed := time.Since(m.sleepTimerStart) + m.sleepTimerRemaining = m.sleepTimerDuration - elapsed + } + } + return m, nil + + case !m.isPlaying && m.playing != nil: + // Paused - resume it. + if err := m.player.Resume(); err == nil { + m.isPlaying = true + if m.sleepTimerActive && m.sleepTimerPaused { + m.sleepTimerPaused = false + m.sleepTimerDuration = m.sleepTimerRemaining + m.sleepTimerStart = time.Now() + waveCmd := m.waveTick() + sleepCmd := m.sleepTimerTick() + return m, tea.Batch(waveCmd, sleepCmd) + } + cmd := m.waveTick() + return m, cmd + } + return m, nil + + case m.focusedSection == SectionStationList && len(m.stations) > 0: + // No station playing and in station list - play selected station. + if item := m.stationList.SelectedItem(); item != nil { + if stationItem, ok := item.(StationItem); ok { + return m, func() tea.Msg { + return playStationMsg{&stationItem.station} + } + } + } + } + return m, nil +} diff --git a/pkg/ui/layout.go b/pkg/ui/layout.go index 1075f87..93e327d 100644 --- a/pkg/ui/layout.go +++ b/pkg/ui/layout.go @@ -89,7 +89,7 @@ func (m *Model) renderFooter() string { shortcuts = "โ†‘โ†“/jk: select โ€ข enter: edit โ€ข c: clear" } - help := fmt.Sprintf("tab: switch sections [%s] โ€ข %s โ€ข space: pause โ€ข +/-: volume โ€ข t: sleep timer โ€ข ctrl+t: theme โ€ข ctrl+c: quit", + help := fmt.Sprintf("tab: switch sections [%s] โ€ข %s โ€ข space: pause โ€ข +/-: volume โ€ข i: identify โ€ข t: sleep timer โ€ข ctrl+t: theme โ€ข ctrl+c: quit", m.focusedSection.String(), shortcuts, ) diff --git a/pkg/ui/model.go b/pkg/ui/model.go index 8352548..1872995 100644 --- a/pkg/ui/model.go +++ b/pkg/ui/model.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "os" "strings" @@ -8,11 +9,13 @@ import ( "charm.land/bubbles/v2/list" "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "github.com/mrwhyte/rig/pkg/config" "github.com/mrwhyte/rig/pkg/favorites" + "github.com/mrwhyte/rig/pkg/identifier" "github.com/mrwhyte/rig/pkg/player" "github.com/mrwhyte/rig/pkg/radiobrowser" "github.com/mrwhyte/rig/pkg/sponsors" @@ -117,6 +120,12 @@ type Model struct { liveSponsors []sponsors.Sponsor // Live sponsors loaded from Gist/cache sponsorScrollOffset int // Top of visible window into the virtual scroll list + // Track identification (Shazam) + showIdentifyModal bool + identifyTrack *identifier.Track + identifyErr error + identifySpinner spinner.Model + identifyCancel context.CancelFunc } // NewModel creates a new application model. @@ -158,6 +167,8 @@ func NewModel() (*Model, error) { ) volumeBar.EmptyColor = colorBorder + identifySpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot)) + m := &Model{ view: ViewLoading, apiClient: apiClient, @@ -173,6 +184,7 @@ func NewModel() (*Model, error) { favManager: favManager, timerInput: timerInput, volumeBar: volumeBar, + identifySpinner: identifySpinner, } return m, nil @@ -601,6 +613,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.volumeBar = newBar return m, cmd + case identifyResultMsg: + // Ignore the result if the user dismissed the modal mid-flight. + if !m.showIdentifyModal { + return m, nil + } + m.identifyTrack = msg.track + m.identifyErr = msg.err + return m, nil + + case spinner.TickMsg: + // Only keep ticking while the identification spinner should be + // visible. Other spinner instances are ignored by ID inside Update. + if !m.isIdentifying() { + return m, nil + } + var cmd tea.Cmd + m.identifySpinner, cmd = m.identifySpinner.Update(msg) + return m, cmd + case waveTickMsg: if m.isPlaying { m.waveFrame++ @@ -637,6 +668,8 @@ func (m *Model) View() tea.View { content = m.renderTimerModal() case m.showThemeModal: content = m.renderThemeModal() + case m.showIdentifyModal: + content = m.renderIdentifyModal() default: switch m.view { case ViewLoading: