Initial cliamp
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
cliamp
|
||||
mp3/
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user