Initial cliamp

This commit is contained in:
Bjarne Øverli
2026-02-24 08:19:22 +01:00
commit b450ef10a7
14 changed files with 1499 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
cliamp
mp3/
+58
View File
@@ -0,0 +1,58 @@
# CLIAMP
A retro terminal music player inspired by Winamp 2.x. MP3 playback with a 10-band spectrum visualizer, 10-band parametric EQ, and playlist management.
Built with [Bubbletea](https://github.com/charmbracelet/bubbletea), [Lip Gloss](https://github.com/charmbracelet/lipgloss), and [Beep](https://github.com/gopxl/beep).
```
╭────────────────────────────────────────────────────────────────╮
│ │
│ C L I A M P │
│ ♫ Artist - Song Title │
│ 01:23 / 04:56 ▶ Playing │
│ │
│ █████ ▇▇▇▇▇ ▅▅▅▅▅ █████ ▃▃▃▃▃ ▅▅▅▅▅ ▇▇▇▇▇ ▃▃▃▃▃ ▂▂▂▂▂ ▁▁▁▁▁ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ VOL ██████████████████░░░░ +0.0dB │
│ EQ 70 180 320 600 1k 3k 6k 12k 14k 16k │
│ │
│ ── Playlist ── [Shuffle] [Repeat: Off] ── │
│ ▶ 1. Song One │
│ 2. Song Two │
│ │
│ [Spc]⏯ [<>]Trk [←→]Seek [+-]Vol [Tab]Focus [Q]Quit │
│ │
╰────────────────────────────────────────────────────────────────╯
```
## Run in dev
```sh
go run . track.mp3
```
## Build
```sh
go build -o cliamp .
./cliamp *.mp3
```
## Keys
| Key | Action |
|---|---|
| `Space` | Play / Pause |
| `s` | Stop |
| `>` `.` | Next track |
| `<` `,` | Previous track |
| `Left` `Right` | Seek -/+5s |
| `+` `-` | Volume up/down |
| `Tab` | Toggle focus (Playlist / EQ) |
| `j` `k` / `Up` `Down` | Playlist scroll / EQ band adjust |
| `h` `l` | EQ cursor left/right |
| `Enter` | Play selected track |
| `r` | Cycle repeat (Off / All / One) |
| `z` | Toggle shuffle |
| `q` | Quit |
+34
View File
@@ -0,0 +1,34 @@
module winamp-cli
go 1.25.5
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/gopxl/beep/v2 v2.1.1
github.com/madelynnblue/go-dsp v1.0.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ebitengine/oto/v3 v3.3.2 // indirect
github.com/ebitengine/purego v0.8.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.14.0 // indirect
)
+65
View File
@@ -0,0 +1,65 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/oto/v3 v3.3.2 h1:VTWBsKX9eb+dXzaF4jEwQbs4yWIdXukJ0K40KgkpYlg=
github.com/ebitengine/oto/v3 v3.3.2/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU=
github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E=
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/madelynnblue/go-dsp v1.0.0 h1:ufzvSGl8IdjCA6BFVUx1cZW/aDiiXxDBWU1MpkrtAiM=
github.com/madelynnblue/go-dsp v1.0.0/go.mod h1:dpf07Rj/u3te6cW3KwRBAqlyjP4InXHhNaYVuY73hHU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"fmt"
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
"github.com/gopxl/beep/v2"
"winamp-cli/player"
"winamp-cli/playlist"
"winamp-cli/ui"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: cliamp <file.mp3> [file2.mp3 ...]")
os.Exit(1)
}
// Expand shell globs that may not have been expanded by the shell
var files []string
for _, arg := range os.Args[1:] {
matches, err := filepath.Glob(arg)
if err != nil || len(matches) == 0 {
files = append(files, arg)
} else {
files = append(files, matches...)
}
}
// Build playlist from file arguments
pl := playlist.New()
for _, f := range files {
pl.Add(playlist.TrackFromPath(f))
}
// Initialize audio engine at CD-quality sample rate
sr := beep.SampleRate(44100)
p := player.New(sr)
defer p.Close()
// Launch the TUI
m := ui.NewModel(p, pl)
prog := tea.NewProgram(m, tea.WithAltScreen())
if _, err := prog.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+2
View File
@@ -0,0 +1,2 @@
[tools]
go = "latest"
+329
View File
@@ -0,0 +1,329 @@
package player
import (
"fmt"
"math"
"os"
"sync"
"sync/atomic"
"time"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/speaker"
)
// EQFreqs are the center frequencies for the 10-band parametric equalizer.
var EQFreqs = [10]float64{70, 180, 320, 600, 1000, 3000, 6000, 12000, 14000, 16000}
// Player is the audio engine managing the playback pipeline:
// [MP3 Decode] -> [Resample] -> [10x Biquad EQ] -> [Volume] -> [Tap] -> [Ctrl] -> [Speaker]
type Player struct {
mu sync.Mutex
sr beep.SampleRate
streamer beep.StreamSeekCloser
format beep.Format
ctrl *beep.Ctrl
volume float64 // dB, range [-30, +6]
eqBands [10]float64
tap *Tap
trackDone atomic.Bool
playing bool
paused bool
file *os.File
}
// New creates a Player and initializes the speaker at the given sample rate.
func New(sr beep.SampleRate) *Player {
speaker.Init(sr, sr.N(time.Second/10))
return &Player{sr: sr}
}
// Play opens and starts playing an MP3 file, building the full audio pipeline.
func (p *Player) Play(path string) error {
p.Stop()
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
streamer, format, err := mp3.Decode(f)
if err != nil {
f.Close()
return fmt.Errorf("decode: %w", err)
}
p.mu.Lock()
p.file = f
p.streamer = streamer
p.format = format
p.trackDone.Store(false)
var s beep.Streamer = streamer
// Resample to target sample rate if needed
if format.SampleRate != p.sr {
s = beep.Resample(4, format.SampleRate, p.sr, s)
}
// Chain 10 biquad peaking EQ filters; each reads its gain from p.eqBands[i]
for i := 0; i < 10; i++ {
s = newBiquad(s, EQFreqs[i], 1.4, &p.eqBands[i], float64(p.sr))
}
// Volume control
s = &volumeStreamer{s: s, vol: &p.volume, mu: &p.mu}
// Tap for FFT visualization
p.tap = NewTap(s, 4096)
// Pause/resume control
p.ctrl = &beep.Ctrl{Streamer: p.tap}
p.playing = true
p.paused = false
p.mu.Unlock()
// Play with end-of-track callback
speaker.Play(beep.Seq(p.ctrl, beep.Callback(func() {
p.trackDone.Store(true)
})))
return nil
}
// TogglePause toggles between paused and playing states.
func (p *Player) TogglePause() {
speaker.Lock()
defer speaker.Unlock()
if p.ctrl != nil {
p.ctrl.Paused = !p.ctrl.Paused
p.paused = p.ctrl.Paused
}
}
// Stop halts playback and releases resources.
func (p *Player) Stop() {
speaker.Clear()
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer != nil {
p.streamer.Close()
p.streamer = nil
}
if p.file != nil {
p.file.Close()
p.file = nil
}
p.ctrl = nil
p.tap = nil
p.playing = false
p.paused = false
p.trackDone.Store(false)
}
// Seek moves the playback position by the given duration (positive or negative).
func (p *Player) Seek(d time.Duration) error {
speaker.Lock()
defer speaker.Unlock()
if p.streamer == nil {
return nil
}
curSample := p.streamer.Position()
curDur := p.format.SampleRate.D(curSample)
newSample := p.format.SampleRate.N(curDur + d)
if newSample < 0 {
newSample = 0
}
if newSample >= p.streamer.Len() {
newSample = p.streamer.Len() - 1
}
return p.streamer.Seek(newSample)
}
// Position returns the current playback position.
func (p *Player) Position() time.Duration {
speaker.Lock()
defer speaker.Unlock()
if p.streamer == nil {
return 0
}
return p.format.SampleRate.D(p.streamer.Position())
}
// Duration returns the total duration of the current track.
func (p *Player) Duration() time.Duration {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return 0
}
return p.format.SampleRate.D(p.streamer.Len())
}
// SetVolume sets the volume in dB, clamped to [-30, +6].
func (p *Player) SetVolume(db float64) {
p.mu.Lock()
defer p.mu.Unlock()
p.volume = max(min(db, 6), -30)
}
// Volume returns the current volume in dB.
func (p *Player) Volume() float64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.volume
}
// SetEQBand sets a single EQ band's gain in dB, clamped to [-12, +12].
func (p *Player) SetEQBand(band int, dB float64) {
if band < 0 || band >= 10 {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.eqBands[band] = max(min(dB, 12), -12)
}
// EQBands returns a copy of all 10 EQ band gains.
func (p *Player) EQBands() [10]float64 {
p.mu.Lock()
defer p.mu.Unlock()
return p.eqBands
}
// IsPlaying returns true if a track is loaded and playing (possibly paused).
func (p *Player) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.playing
}
// IsPaused returns true if playback is paused.
func (p *Player) IsPaused() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.paused
}
// TrackDone returns true if the current track has finished playing.
func (p *Player) TrackDone() bool {
return p.trackDone.Load()
}
// Samples returns the latest audio samples from the tap for FFT analysis.
func (p *Player) Samples() []float64 {
p.mu.Lock()
tap := p.tap
p.mu.Unlock()
if tap == nil {
return nil
}
return tap.Samples(2048)
}
// Close stops playback and cleans up.
func (p *Player) Close() {
p.Stop()
}
// --- Volume Streamer ---
type volumeStreamer struct {
s beep.Streamer
vol *float64
mu *sync.Mutex
}
func (v *volumeStreamer) Stream(samples [][2]float64) (int, bool) {
n, ok := v.s.Stream(samples)
v.mu.Lock()
gain := math.Pow(10, *v.vol/20)
v.mu.Unlock()
for i := 0; i < n; i++ {
samples[i][0] *= gain
samples[i][1] *= gain
}
return n, ok
}
func (v *volumeStreamer) Err() error { return v.s.Err() }
// --- Biquad Peaking EQ Filter ---
// Implements a second-order IIR peaking equalizer per the Audio EQ Cookbook.
// Each filter reads its gain from a shared pointer, so EQ changes take
// effect on the next Stream() call without rebuilding the pipeline.
type biquad struct {
s beep.Streamer
freq float64
q float64
gain *float64 // points to Player.eqBands[i]
sr float64
// Per-channel filter state
x1, x2 [2]float64
y1, y2 [2]float64
// Cached coefficients
lastGain float64
b0, b1, b2, a1, a2 float64
inited bool
}
func newBiquad(s beep.Streamer, freq, q float64, gain *float64, sr float64) *biquad {
return &biquad{s: s, freq: freq, q: q, gain: gain, sr: sr}
}
func (b *biquad) calcCoeffs(dB float64) {
if b.inited && dB == b.lastGain {
return
}
b.lastGain = dB
b.inited = true
A := math.Pow(10, dB/40)
w0 := 2 * math.Pi * b.freq / b.sr
sinW0 := math.Sin(w0)
cosW0 := math.Cos(w0)
alpha := sinW0 / (2 * b.q)
b0 := 1 + alpha*A
b1 := -2 * cosW0
b2 := 1 - alpha*A
a0 := 1 + alpha/A
a1 := -2 * cosW0
a2 := 1 - alpha/A
b.b0 = b0 / a0
b.b1 = b1 / a0
b.b2 = b2 / a0
b.a1 = a1 / a0
b.a2 = a2 / a0
}
func (b *biquad) Stream(samples [][2]float64) (int, bool) {
n, ok := b.s.Stream(samples)
dB := *b.gain
// Skip processing when gain is effectively zero
if dB > -0.1 && dB < 0.1 {
return n, ok
}
b.calcCoeffs(dB)
for i := 0; i < n; i++ {
for ch := 0; ch < 2; ch++ {
x := samples[i][ch]
y := b.b0*x + b.b1*b.x1[ch] + b.b2*b.x2[ch] - b.a1*b.y1[ch] - b.a2*b.y2[ch]
b.x2[ch] = b.x1[ch]
b.x1[ch] = x
b.y2[ch] = b.y1[ch]
b.y1[ch] = y
samples[i][ch] = y
}
}
return n, ok
}
func (b *biquad) Err() error { return b.s.Err() }
+59
View File
@@ -0,0 +1,59 @@
package player
import (
"sync"
"github.com/gopxl/beep/v2"
)
// Tap is a streamer wrapper that copies samples into a ring buffer
// for real-time FFT visualization. It sits in the audio pipeline
// between the volume control and the speaker controller.
type Tap struct {
s beep.Streamer
mu sync.Mutex
buf []float64
pos int
size int
}
// NewTap wraps a streamer with a ring buffer of the given size.
func NewTap(s beep.Streamer, bufSize int) *Tap {
return &Tap{
s: s,
buf: make([]float64, bufSize),
size: bufSize,
}
}
// Stream passes audio through while capturing a mono mix into the ring buffer.
func (t *Tap) Stream(samples [][2]float64) (int, bool) {
n, ok := t.s.Stream(samples)
t.mu.Lock()
for i := 0; i < n; i++ {
t.buf[t.pos] = (samples[i][0] + samples[i][1]) / 2
t.pos = (t.pos + 1) % t.size
}
t.mu.Unlock()
return n, ok
}
// Err returns the underlying streamer's error.
func (t *Tap) Err() error {
return t.s.Err()
}
// Samples returns the last n samples from the ring buffer in chronological order.
func (t *Tap) Samples(n int) []float64 {
if n > t.size {
n = t.size
}
out := make([]float64, n)
t.mu.Lock()
start := (t.pos - n + t.size) % t.size
for i := 0; i < n; i++ {
out[i] = t.buf[(start+i)%t.size]
}
t.mu.Unlock()
return out
}
+191
View File
@@ -0,0 +1,191 @@
package playlist
import (
"math/rand"
"path/filepath"
"strings"
)
// RepeatMode controls playlist repeat behavior.
type RepeatMode int
const (
RepeatOff RepeatMode = iota
RepeatAll
RepeatOne
)
func (r RepeatMode) String() string {
switch r {
case RepeatAll:
return "All"
case RepeatOne:
return "One"
default:
return "Off"
}
}
// Track represents a single audio file.
type Track struct {
Path string
Title string
Artist string
}
// TrackFromPath creates a Track by parsing the filename.
// Supports "Artist - Title" format, otherwise uses the filename as title.
func TrackFromPath(path string) Track {
base := filepath.Base(path)
name := strings.TrimSuffix(base, filepath.Ext(base))
parts := strings.SplitN(name, " - ", 2)
if len(parts) == 2 {
return Track{Path: path, Artist: strings.TrimSpace(parts[0]), Title: strings.TrimSpace(parts[1])}
}
return Track{Path: path, Title: name}
}
// DisplayName returns a formatted display string for the track.
func (t Track) DisplayName() string {
if t.Artist != "" {
return t.Artist + " - " + t.Title
}
return t.Title
}
// Playlist manages an ordered list of tracks with shuffle and repeat support.
type Playlist struct {
tracks []Track
order []int // indices into tracks, shuffled or sequential
pos int // current position in order
shuffle bool
repeat RepeatMode
}
// New creates an empty Playlist.
func New() *Playlist {
return &Playlist{}
}
// Add appends tracks to the playlist.
func (p *Playlist) Add(tracks ...Track) {
start := len(p.tracks)
p.tracks = append(p.tracks, tracks...)
for i := start; i < len(p.tracks); i++ {
p.order = append(p.order, i)
}
}
// Len returns the number of tracks.
func (p *Playlist) Len() int { return len(p.tracks) }
// Current returns the currently selected track and its index.
func (p *Playlist) Current() (Track, int) {
if len(p.tracks) == 0 {
return Track{}, -1
}
idx := p.order[p.pos]
return p.tracks[idx], idx
}
// Index returns the track index of the current position.
func (p *Playlist) Index() int {
if len(p.order) == 0 {
return -1
}
return p.order[p.pos]
}
// Next advances to the next track. Returns false if at end with repeat off.
func (p *Playlist) Next() (Track, bool) {
if len(p.tracks) == 0 {
return Track{}, false
}
if p.repeat == RepeatOne {
return p.tracks[p.order[p.pos]], true
}
if p.pos+1 < len(p.order) {
p.pos++
return p.tracks[p.order[p.pos]], true
}
if p.repeat == RepeatAll {
p.pos = 0
if p.shuffle {
p.doShuffle()
}
return p.tracks[p.order[p.pos]], true
}
return Track{}, false
}
// Prev moves to the previous track. Wraps around with RepeatAll.
func (p *Playlist) Prev() (Track, bool) {
if len(p.tracks) == 0 {
return Track{}, false
}
if p.pos > 0 {
p.pos--
return p.tracks[p.order[p.pos]], true
}
if p.repeat == RepeatAll {
p.pos = len(p.order) - 1
return p.tracks[p.order[p.pos]], true
}
return p.tracks[p.order[p.pos]], true
}
// SetIndex sets the current position to the given track index.
func (p *Playlist) SetIndex(i int) {
for pos, idx := range p.order {
if idx == i {
p.pos = pos
return
}
}
}
// Tracks returns all tracks in the playlist.
func (p *Playlist) Tracks() []Track { return p.tracks }
// ToggleShuffle enables or disables shuffle mode.
// Uses Fisher-Yates shuffle, preserving the current track at position 0.
func (p *Playlist) ToggleShuffle() {
p.shuffle = !p.shuffle
if p.shuffle {
p.doShuffle()
} else {
cur := p.order[p.pos]
p.order = make([]int, len(p.tracks))
for i := range p.order {
p.order[i] = i
}
p.pos = cur
}
}
func (p *Playlist) doShuffle() {
cur := p.order[p.pos]
others := make([]int, 0, len(p.tracks)-1)
for i := 0; i < len(p.tracks); i++ {
if i != cur {
others = append(others, i)
}
}
for i := len(others) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
others[i], others[j] = others[j], others[i]
}
p.order = append([]int{cur}, others...)
p.pos = 0
}
// CycleRepeat cycles through Off -> All -> One.
func (p *Playlist) CycleRepeat() {
p.repeat = (p.repeat + 1) % 3
}
// Shuffled returns whether shuffle is enabled.
func (p *Playlist) Shuffled() bool { return p.shuffle }
// Repeat returns the current repeat mode.
func (p *Playlist) Repeat() RepeatMode { return p.repeat }
+110
View File
@@ -0,0 +1,110 @@
package ui
import (
"time"
tea "github.com/charmbracelet/bubbletea"
)
// handleKey processes a single key press and returns an optional command.
func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd {
switch msg.String() {
case "q", "ctrl+c":
m.player.Close()
m.quitting = true
return tea.Quit
case " ":
if !m.player.IsPlaying() {
m.playCurrentTrack()
} else {
m.player.TogglePause()
}
case "s":
m.player.Stop()
case ">", ".":
m.nextTrack()
case "<", ",":
m.prevTrack()
case "left":
if m.focus == focusEQ {
if m.eqCursor > 0 {
m.eqCursor--
}
} else {
m.player.Seek(-5 * time.Second)
}
case "right":
if m.focus == focusEQ {
if m.eqCursor < 9 {
m.eqCursor++
}
} else {
m.player.Seek(5 * time.Second)
}
case "up", "k":
if m.focus == focusEQ {
bands := m.player.EQBands()
m.player.SetEQBand(m.eqCursor, bands[m.eqCursor]+1)
} else {
if m.plCursor > 0 {
m.plCursor--
m.adjustScroll()
}
}
case "down", "j":
if m.focus == focusEQ {
bands := m.player.EQBands()
m.player.SetEQBand(m.eqCursor, bands[m.eqCursor]-1)
} else {
if m.plCursor < m.playlist.Len()-1 {
m.plCursor++
m.adjustScroll()
}
}
case "enter":
if m.focus == focusPlaylist {
m.playlist.SetIndex(m.plCursor)
m.playCurrentTrack()
}
case "+", "=":
m.player.SetVolume(m.player.Volume() + 1)
case "-":
m.player.SetVolume(m.player.Volume() - 1)
case "r":
m.playlist.CycleRepeat()
case "z":
m.playlist.ToggleShuffle()
case "tab":
if m.focus == focusPlaylist {
m.focus = focusEQ
} else {
m.focus = focusPlaylist
}
case "h":
if m.focus == focusEQ && m.eqCursor > 0 {
m.eqCursor--
}
case "l":
if m.focus == focusEQ && m.eqCursor < 9 {
m.eqCursor++
}
}
return nil
}
+135
View File
@@ -0,0 +1,135 @@
package ui
import (
"time"
"winamp-cli/player"
"winamp-cli/playlist"
tea "github.com/charmbracelet/bubbletea"
)
type focusArea int
const (
focusPlaylist focusArea = iota
focusEQ
)
type tickMsg time.Time
// Model is the Bubbletea model for the CLIAMP TUI.
type Model struct {
player *player.Player
playlist *playlist.Playlist
vis *Visualizer
focus focusArea
eqCursor int // selected EQ band (0-9)
plCursor int // selected playlist item
plScroll int // scroll offset for playlist view
plVisible int // max visible playlist items
titleOff int // scroll offset for long track titles
err error
quitting bool
width int
height int
}
// NewModel creates a Model wired to the given player and playlist.
func NewModel(p *player.Player, pl *playlist.Playlist) Model {
return Model{
player: p,
playlist: pl,
vis: NewVisualizer(44100),
plVisible: 5,
}
}
// Init starts the tick timer and requests the terminal size.
func (m Model) Init() tea.Cmd {
return tea.Batch(tickCmd(), tea.WindowSize())
}
func tickCmd() tea.Cmd {
return tea.Tick(time.Millisecond*50, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Update handles messages: key presses, ticks, and window resizes.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
cmd := m.handleKey(msg)
if m.quitting {
return m, tea.Quit
}
return m, cmd
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tickMsg:
// Check if the current track finished naturally
if m.player.IsPlaying() && !m.player.IsPaused() && m.player.TrackDone() {
m.nextTrack()
}
m.titleOff++
return m, tickCmd()
}
return m, nil
}
// nextTrack advances to the next playlist track and starts playing it.
func (m *Model) nextTrack() {
track, ok := m.playlist.Next()
if ok {
m.plCursor = m.playlist.Index()
m.adjustScroll()
if err := m.player.Play(track.Path); err != nil {
m.err = err
}
} else {
m.player.Stop()
}
}
// prevTrack goes to the previous track, or restarts if >3s into the current one.
func (m *Model) prevTrack() {
if m.player.Position() > 3*time.Second {
m.player.Seek(-m.player.Position())
return
}
track, ok := m.playlist.Prev()
if ok {
m.plCursor = m.playlist.Index()
m.adjustScroll()
if err := m.player.Play(track.Path); err != nil {
m.err = err
}
}
}
// playCurrentTrack starts playing whatever track the playlist cursor points to.
func (m *Model) playCurrentTrack() {
track, idx := m.playlist.Current()
if idx < 0 {
return
}
m.titleOff = 0
if err := m.player.Play(track.Path); err != nil {
m.err = err
}
}
// adjustScroll ensures plCursor is visible in the playlist view.
func (m *Model) adjustScroll() {
if m.plCursor < m.plScroll {
m.plScroll = m.plCursor
}
if m.plCursor >= m.plScroll+m.plVisible {
m.plScroll = m.plCursor - m.plVisible + 1
}
}
+75
View File
@@ -0,0 +1,75 @@
package ui
import "github.com/charmbracelet/lipgloss"
// CLIAMP color palette using standard ANSI terminal colors (0-15).
// These adapt to the user's terminal theme for consistent appearance.
var (
colorBorder = lipgloss.ANSIColor(8) // bright black (dark gray)
colorTitle = lipgloss.ANSIColor(10) // bright green
colorText = lipgloss.ANSIColor(7) // white (light gray)
colorDim = lipgloss.ANSIColor(8) // bright black (dark gray)
colorAccent = lipgloss.ANSIColor(11) // bright yellow
colorPlaying = lipgloss.ANSIColor(10) // bright green
colorSeekBar = lipgloss.ANSIColor(11) // bright yellow
colorVolume = lipgloss.ANSIColor(2) // green
// Spectrum gradient: green -> yellow -> red
spectrumLow = lipgloss.ANSIColor(10) // bright green
spectrumMid = lipgloss.ANSIColor(11) // bright yellow
spectrumHigh = lipgloss.ANSIColor(9) // bright red
)
// Lip Gloss styles
var (
frameStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(1, 2).
Width(66)
titleStyle = lipgloss.NewStyle().
Foreground(colorTitle).
Bold(true)
trackStyle = lipgloss.NewStyle().
Foreground(colorAccent)
timeStyle = lipgloss.NewStyle().
Foreground(colorText)
statusStyle = lipgloss.NewStyle().
Foreground(colorPlaying).
Bold(true)
dimStyle = lipgloss.NewStyle().
Foreground(colorDim)
labelStyle = lipgloss.NewStyle().
Foreground(colorText).
Bold(true)
eqActiveStyle = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
eqInactiveStyle = lipgloss.NewStyle().
Foreground(colorDim)
playlistActiveStyle = lipgloss.NewStyle().
Foreground(colorPlaying).
Bold(true)
playlistItemStyle = lipgloss.NewStyle().
Foreground(colorText)
playlistSelectedStyle = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
helpStyle = lipgloss.NewStyle().
Foreground(colorDim)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.ANSIColor(9)) // bright red
)
+254
View File
@@ -0,0 +1,254 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
const panelWidth = 60 // usable inner width (66 frame - 2 border - 4 padding)
// View renders the full TUI frame.
func (m Model) View() string {
if m.quitting {
return ""
}
sections := []string{
// Now playing
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
// Visualizer
m.renderSpectrum(),
m.renderSeekBar(),
"",
// Controls
m.renderVolume(),
m.renderEQ(),
"",
// Playlist
m.renderPlaylistHeader(),
m.renderPlaylist(),
"",
// Help
m.renderHelp(),
}
if m.err != nil {
sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)))
}
content := strings.Join(sections, "\n")
return frameStyle.Render(content)
}
func (m Model) renderTitle() string {
return titleStyle.Render("C L I A M P")
}
func (m Model) renderTrackInfo() string {
track, _ := m.playlist.Current()
name := track.DisplayName()
if name == "" {
name = "No track loaded"
}
maxW := panelWidth - 4
runes := []rune(name)
if len(runes) <= maxW {
return trackStyle.Render("♫ " + name)
}
// Cyclic scrolling for long titles
sep := []rune(" ♫ ")
padded := append(runes, sep...)
total := len(padded)
off := m.titleOff % total
display := make([]rune, maxW)
for i := 0; i < maxW; i++ {
display[i] = padded[(off+i)%total]
}
return trackStyle.Render("♫ " + string(display))
}
func (m Model) renderTimeStatus() string {
pos := m.player.Position()
dur := m.player.Duration()
posMin := int(pos.Minutes())
posSec := int(pos.Seconds()) % 60
durMin := int(dur.Minutes())
durSec := int(dur.Seconds()) % 60
timeStr := fmt.Sprintf("%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec)
var status string
if m.player.IsPlaying() {
if m.player.IsPaused() {
status = statusStyle.Render("⏸ Paused")
} else {
status = statusStyle.Render("▶ Playing")
}
} else {
status = dimStyle.Render("■ Stopped")
}
left := timeStyle.Render(timeStr)
gap := panelWidth - lipgloss.Width(left) - lipgloss.Width(status)
if gap < 1 {
gap = 1
}
return left + strings.Repeat(" ", gap) + status
}
func (m Model) renderSpectrum() string {
samples := m.player.Samples()
var bands [numBands]float64
if len(samples) > 0 {
bands = m.vis.Analyze(samples)
} else {
bands = m.vis.Analyze(nil)
}
return m.vis.Render(bands)
}
func (m Model) renderSeekBar() string {
pos := m.player.Position()
dur := m.player.Duration()
barWidth := panelWidth
var progress float64
if dur > 0 {
progress = float64(pos) / float64(dur)
}
if progress > 1 {
progress = 1
}
filled := int(progress * float64(barWidth-1))
if filled < 0 {
filled = 0
}
seekFill := lipgloss.NewStyle().Foreground(colorSeekBar)
seekDim := lipgloss.NewStyle().Foreground(colorDim)
return seekFill.Render(strings.Repeat("━", filled)) +
seekFill.Render("●") +
seekDim.Render(strings.Repeat("━", max(0, barWidth-filled-1)))
}
func (m Model) renderVolume() string {
vol := m.player.Volume()
frac := (vol + 30) / 36
if frac < 0 {
frac = 0
}
if frac > 1 {
frac = 1
}
barWidth := 22
filled := int(frac * float64(barWidth))
volStyle := lipgloss.NewStyle().Foreground(colorVolume)
bar := volStyle.Render(strings.Repeat("█", filled)) +
dimStyle.Render(strings.Repeat("░", barWidth-filled))
return labelStyle.Render("VOL ") + bar + dimStyle.Render(fmt.Sprintf(" %+.1fdB", vol))
}
func (m Model) renderEQ() string {
bands := m.player.EQBands()
labels := []string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"}
var parts []string
for i, label := range labels {
style := eqInactiveStyle
if m.focus == focusEQ && i == m.eqCursor {
style = eqActiveStyle
label = fmt.Sprintf("%+.0f", bands[i])
}
parts = append(parts, style.Render(label))
}
return labelStyle.Render("EQ ") + strings.Join(parts, " ")
}
func (m Model) renderPlaylistHeader() string {
var shuffle string
if m.playlist.Shuffled() {
shuffle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render("[Shuffle]")
} else {
shuffle = dimStyle.Render("[Shuffle]")
}
repeatMode := m.playlist.Repeat()
repeatStr := fmt.Sprintf("[Repeat: %s]", repeatMode)
if repeatMode != 0 {
repeatStr = lipgloss.NewStyle().Foreground(colorAccent).Bold(true).Render(repeatStr)
} else {
repeatStr = dimStyle.Render(repeatStr)
}
return dimStyle.Render("── Playlist ── ") + shuffle + " " + repeatStr + " " + dimStyle.Render("──")
}
func (m Model) renderPlaylist() string {
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
return dimStyle.Render(" No tracks loaded")
}
currentIdx := m.playlist.Index()
visible := m.plVisible
if visible > len(tracks) {
visible = len(tracks)
}
scroll := m.plScroll
if scroll+visible > len(tracks) {
scroll = len(tracks) - visible
}
if scroll < 0 {
scroll = 0
}
var lines []string
for i := scroll; i < scroll+visible && i < len(tracks); i++ {
prefix := " "
style := playlistItemStyle
if i == currentIdx && m.player.IsPlaying() {
prefix = "▶ "
style = playlistActiveStyle
}
if m.focus == focusPlaylist && i == m.plCursor {
style = playlistSelectedStyle
}
name := tracks[i].DisplayName()
maxW := panelWidth - 6
nameRunes := []rune(name)
if len(nameRunes) > maxW {
name = string(nameRunes[:maxW-1]) + "…"
}
line := style.Render(fmt.Sprintf("%s%d. %s", prefix, i+1, name))
lines = append(lines, line)
}
return strings.Join(lines, "\n")
}
func (m Model) renderHelp() string {
return helpStyle.Render("[Spc]⏯ [<>]Trk [←→]Seek [+-]Vol [Tab]Focus [Q]Quit")
}
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"math"
"math/cmplx"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/madelynnblue/go-dsp/fft"
)
const (
numBands = 10
fftSize = 2048
)
// Unicode block elements for bar height (8 levels)
var barBlocks = []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
// Frequency edges for 10 spectrum bands (Hz)
var bandEdges = [11]float64{20, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 16000, 20000}
// Visualizer performs FFT analysis and renders spectrum bars.
type Visualizer struct {
prev [numBands]float64 // previous frame for temporal smoothing
sr float64
}
// NewVisualizer creates a Visualizer for the given sample rate.
func NewVisualizer(sampleRate float64) *Visualizer {
return &Visualizer{sr: sampleRate}
}
// Analyze runs FFT on raw audio samples and returns 10 normalized band levels (0-1).
func (v *Visualizer) Analyze(samples []float64) [numBands]float64 {
var bands [numBands]float64
if len(samples) == 0 {
// Decay previous values
for b := 0; b < numBands; b++ {
bands[b] = v.prev[b] * 0.8
v.prev[b] = bands[b]
}
return bands
}
// Zero-pad or truncate to fftSize
buf := make([]float64, fftSize)
copy(buf, samples)
// Apply Hann window to reduce spectral leakage
for i := 0; i < fftSize; i++ {
w := 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(fftSize-1)))
buf[i] *= w
}
// Compute FFT
spectrum := fft.FFTReal(buf)
binHz := v.sr / float64(fftSize)
// Sum magnitudes per frequency band
for b := 0; b < numBands; b++ {
loIdx := int(bandEdges[b] / binHz)
hiIdx := int(bandEdges[b+1] / binHz)
if loIdx < 1 {
loIdx = 1
}
halfLen := len(spectrum) / 2
if hiIdx >= halfLen {
hiIdx = halfLen - 1
}
var sum float64
count := 0
for i := loIdx; i <= hiIdx; i++ {
sum += cmplx.Abs(spectrum[i])
count++
}
if count > 0 {
sum /= float64(count)
}
// Convert to dB-like scale and normalize to 0-1
if sum > 0 {
bands[b] = (20*math.Log10(sum) + 10) / 50
}
bands[b] = max(0, min(1, bands[b]))
// Temporal smoothing: fast attack, slow decay
if bands[b] > v.prev[b] {
bands[b] = bands[b]*0.6 + v.prev[b]*0.4
} else {
bands[b] = bands[b]*0.25 + v.prev[b]*0.75
}
v.prev[b] = bands[b]
}
return bands
}
// Render converts band levels into a colored spectrum bar string.
func (v *Visualizer) Render(bands [numBands]float64) string {
var sb strings.Builder
for i, level := range bands {
idx := int(level * float64(len(barBlocks)-1))
if idx < 0 {
idx = 0
}
if idx >= len(barBlocks) {
idx = len(barBlocks) - 1
}
block := barBlocks[idx]
// Color gradient: green -> yellow -> red based on level
var style lipgloss.Style
switch {
case level > 0.75:
style = lipgloss.NewStyle().Foreground(spectrumHigh)
case level > 0.45:
style = lipgloss.NewStyle().Foreground(spectrumMid)
default:
style = lipgloss.NewStyle().Foreground(spectrumLow)
}
sb.WriteString(style.Render(strings.Repeat(block, 5)))
if i < numBands-1 {
sb.WriteString(" ")
}
}
return sb.String()
}