1033 lines
31 KiB
Go
1033 lines
31 KiB
Go
package ui
|
||
|
||
import (
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
"charm.land/lipgloss/v2"
|
||
)
|
||
|
||
const (
|
||
DefaultSpectrumBands = 10
|
||
defaultFFTSize = 2048
|
||
DefaultVisRows = 5
|
||
minSpectrumHz = 20.0
|
||
maxSpectrumHz = 20000.0
|
||
// Cap on dt fed into smoothing easing — long gaps (sleep, paused, stalled
|
||
// frame) step like ~1 frame instead of integrating over a huge interval.
|
||
maxSmoothDtFrames = 10
|
||
)
|
||
|
||
var legacySpectrumEdges = [DefaultSpectrumBands + 1]float64{
|
||
minSpectrumHz,
|
||
100,
|
||
200,
|
||
400,
|
||
800,
|
||
1600,
|
||
3200,
|
||
6400,
|
||
12800,
|
||
16000,
|
||
maxSpectrumHz,
|
||
}
|
||
|
||
// VisMode selects the visualizer rendering style.
|
||
type VisMode int
|
||
|
||
const (
|
||
VisBars VisMode = iota // smooth fractional blocks
|
||
VisBarsDot // bars with braille dot stipple
|
||
VisRain // falling rain droplets within bar shapes
|
||
VisBarsOutline // top-edge outline of bars
|
||
VisBricks // solid bricks with gaps
|
||
VisColumns // many thin columns
|
||
VisClassicPeak // classic falling peak caps over thin columns
|
||
VisWave // braille waveform oscilloscope
|
||
VisScatter // braille particle sparkle
|
||
VisFlame // braille rising flame tendrils
|
||
VisRetro // 80s synthwave perspective grid with wave
|
||
VisPulse // braille pulsating circle
|
||
VisMatrix // falling matrix rain characters
|
||
VisBinary // streaming binary 0s and 1s
|
||
VisSakura // falling cherry blossom petals
|
||
VisFirework // exploding firework bursts
|
||
VisBubbles // rising hollow ring bubbles
|
||
VisLogo // CLIAMP pixel text
|
||
VisTerrain // scrolling side-view mountain range
|
||
VisScope // Lissajous XY oscilloscope
|
||
VisHeartbeat // ECG pulse monitor trace
|
||
VisButterfly // mirrored Rorschach spectrum
|
||
VisAscii // dense shade-block columns (website style)
|
||
VisFirefly // firefly meadow at dusk
|
||
VisMosaic // static heatmap of flickering tiles
|
||
VisSand // falling-sand cellular automaton
|
||
VisGeyser // bass-driven particle fountain
|
||
VisClassicLED // Winamp 2.9 LED matrix with falling peak caps
|
||
VisNone // hidden — no visualizer
|
||
VisCount // sentinel for cycling
|
||
)
|
||
|
||
// Unicode block elements for bar height (9 levels including space)
|
||
var barBlocks = []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
|
||
|
||
// brailleBit maps (row, col) in a 4×2 Braille dot grid to its bit value.
|
||
var brailleBit = [4][2]rune{
|
||
{0x01, 0x08}, // row 0
|
||
{0x02, 0x10}, // row 1
|
||
{0x04, 0x20}, // row 2
|
||
{0x40, 0x80}, // row 3
|
||
}
|
||
|
||
// visBandWidth returns the character width for band b so that all bands plus
|
||
// 1-char gaps exactly fill PanelWidth. The remainder is distributed across the
|
||
// first few bands.
|
||
func visBandWidth(totalBands, b int) int {
|
||
const gap = 1
|
||
if totalBands <= 0 {
|
||
return 0
|
||
}
|
||
base := (PanelWidth - (totalBands-1)*gap) / totalBands
|
||
extra := (PanelWidth - (totalBands-1)*gap) % totalBands
|
||
if b < extra {
|
||
return base + 1
|
||
}
|
||
return base
|
||
}
|
||
|
||
// interpolateBandColumns builds per-column levels by interpolating between neighboring bands.
|
||
func interpolateBandColumns(bands []float64, bandCols []int) []float64 {
|
||
totalCols := 0
|
||
for _, width := range bandCols {
|
||
totalCols += width
|
||
}
|
||
|
||
cols := make([]float64, totalCols)
|
||
offset := 0
|
||
for b, level := range bands {
|
||
width := bandCols[b]
|
||
if width <= 0 {
|
||
continue
|
||
}
|
||
nextLevel := level
|
||
if b+1 < len(bands) {
|
||
nextLevel = bands[b+1]
|
||
}
|
||
for c := range width {
|
||
t := float64(c) / float64(width)
|
||
cols[offset+c] = level*(1-t) + nextLevel*t
|
||
}
|
||
offset += width
|
||
}
|
||
return cols
|
||
}
|
||
|
||
func sampleBandLinear(bands []float64, pos float64) float64 {
|
||
switch len(bands) {
|
||
case 0:
|
||
return 0
|
||
case 1:
|
||
return bands[0]
|
||
}
|
||
if pos <= 0 {
|
||
return bands[0]
|
||
}
|
||
last := float64(len(bands) - 1)
|
||
if pos >= last {
|
||
return bands[len(bands)-1]
|
||
}
|
||
idx := int(pos)
|
||
frac := pos - float64(idx)
|
||
return bands[idx]*(1-frac) + bands[idx+1]*frac
|
||
}
|
||
|
||
func resampleBandsLinear(bands []float64, totalCols int) []float64 {
|
||
if totalCols <= 0 || len(bands) == 0 {
|
||
return nil
|
||
}
|
||
if len(bands) == totalCols {
|
||
out := make([]float64, len(bands))
|
||
copy(out, bands)
|
||
return out
|
||
}
|
||
out := make([]float64, totalCols)
|
||
if totalCols == 1 {
|
||
out[0] = sampleBandLinear(bands, float64(len(bands)-1)/2)
|
||
return out
|
||
}
|
||
last := float64(len(bands) - 1)
|
||
for col := range totalCols {
|
||
pos := float64(col) / float64(totalCols-1) * last
|
||
out[col] = sampleBandLinear(bands, pos)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func averageSpectrumRangeLinear(magnitudes []float64, loPos, hiPos float64) float64 {
|
||
if len(magnitudes) == 0 {
|
||
return 0
|
||
}
|
||
minPos := 1.0
|
||
maxPos := float64(len(magnitudes) - 1)
|
||
loPos = max(minPos, min(maxPos, loPos))
|
||
hiPos = max(loPos, min(maxPos, hiPos))
|
||
span := hiPos - loPos
|
||
if span <= 0 {
|
||
return sampleBandLinear(magnitudes, loPos)
|
||
}
|
||
sampleCount := max(4, min(32, int(math.Ceil(span*2))))
|
||
var sum float64
|
||
for i := range sampleCount {
|
||
t := (float64(i) + 0.5) / float64(sampleCount)
|
||
sum += sampleBandLinear(magnitudes, loPos+t*span)
|
||
}
|
||
return sum / float64(sampleCount)
|
||
}
|
||
|
||
// Pre-built styles for spectrum bar colors to avoid per-frame allocation.
|
||
var (
|
||
specLowStyle = lipgloss.NewStyle().Foreground(SpectrumLow)
|
||
specMidStyle = lipgloss.NewStyle().Foreground(SpectrumMid)
|
||
specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh)
|
||
)
|
||
|
||
// Raw ANSI wrappers for the spectrum styles. Caching these once lets every
|
||
// style-run flush skip lipgloss.Render (which allocates a fresh wrapped string
|
||
// per call) and instead stream prefix + body + suffix into an existing builder.
|
||
// Rebuilt via refreshSpecANSI on theme changes.
|
||
var (
|
||
specLowPrefix, specLowSuffix string
|
||
specMidPrefix, specMidSuffix string
|
||
specHighPrefix, specHighSuffix string
|
||
)
|
||
|
||
func init() {
|
||
refreshSpecANSI()
|
||
}
|
||
|
||
func refreshSpecANSI() {
|
||
specLowPrefix, specLowSuffix = splitStyleAroundProbe(specLowStyle)
|
||
specMidPrefix, specMidSuffix = splitStyleAroundProbe(specMidStyle)
|
||
specHighPrefix, specHighSuffix = splitStyleAroundProbe(specHighStyle)
|
||
}
|
||
|
||
// splitStyleAroundProbe renders a rare marker through the style and splits the
|
||
// output around it, yielding the ANSI prefix and suffix the style applies.
|
||
// Works for plain Foreground-only styles; adding borders or padding would
|
||
// invalidate the split.
|
||
func splitStyleAroundProbe(s lipgloss.Style) (prefix, suffix string) {
|
||
const probe = "\uFFFC"
|
||
rendered := s.Render(probe)
|
||
idx := strings.Index(rendered, probe)
|
||
if idx < 0 {
|
||
return "", ""
|
||
}
|
||
return rendered[:idx], rendered[idx+len(probe):]
|
||
}
|
||
|
||
type VisTickContext struct {
|
||
Now time.Time
|
||
Playing bool
|
||
Paused bool
|
||
OverlayActive bool
|
||
Analyze func(VisAnalysisSpec) []float64
|
||
}
|
||
|
||
type VisAnalysisSpec struct {
|
||
BandCount int
|
||
FFTSize int
|
||
}
|
||
|
||
func spectrumAnalysisSpec(bandCount int) VisAnalysisSpec {
|
||
return VisAnalysisSpec{
|
||
BandCount: bandCount,
|
||
FFTSize: defaultFFTSize,
|
||
}
|
||
}
|
||
|
||
func NormalizeAnalysisSpec(spec VisAnalysisSpec) VisAnalysisSpec {
|
||
if spec.BandCount < 0 {
|
||
spec.BandCount = 0
|
||
}
|
||
if spec.FFTSize <= 0 {
|
||
spec.FFTSize = defaultFFTSize
|
||
}
|
||
return spec
|
||
}
|
||
|
||
type visModeDriver interface {
|
||
AnalysisSpec(*Visualizer) VisAnalysisSpec
|
||
Render(*Visualizer) string
|
||
Tick(*Visualizer, VisTickContext)
|
||
TickInterval(*Visualizer, VisTickContext) time.Duration
|
||
OnEnter(*Visualizer)
|
||
OnLeave(*Visualizer)
|
||
}
|
||
|
||
// visEntry pairs a display name with a factory for that mode's visModeDriver.
|
||
type visEntry struct {
|
||
name string
|
||
newDriver func() visModeDriver
|
||
}
|
||
|
||
type renderOnlyDriver struct {
|
||
spec VisAnalysisSpec
|
||
render func(*Visualizer, []float64) string
|
||
tickDuration time.Duration // 0 = use defaultDriverTickInterval
|
||
}
|
||
|
||
func (d *renderOnlyDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
|
||
return d.spec
|
||
}
|
||
|
||
func (d *renderOnlyDriver) Render(v *Visualizer) string {
|
||
return d.render(v, v.SmoothedBands())
|
||
}
|
||
|
||
func (d *renderOnlyDriver) Tick(v *Visualizer, ctx VisTickContext) {
|
||
defaultDriverTick(v, ctx, d.spec)
|
||
}
|
||
|
||
func (d *renderOnlyDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
|
||
if d.tickDuration > 0 && ctx.Playing && !ctx.OverlayActive {
|
||
return d.tickDuration
|
||
}
|
||
return defaultDriverTickInterval(ctx)
|
||
}
|
||
|
||
func (*renderOnlyDriver) OnEnter(*Visualizer) {}
|
||
|
||
func (*renderOnlyDriver) OnLeave(*Visualizer) {}
|
||
|
||
type noOpDriver struct{}
|
||
|
||
func (*noOpDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec { return VisAnalysisSpec{} }
|
||
|
||
func (*noOpDriver) Render(*Visualizer) string { return "" }
|
||
|
||
func (*noOpDriver) Tick(*Visualizer, VisTickContext) {}
|
||
|
||
func (*noOpDriver) TickInterval(*Visualizer, VisTickContext) time.Duration { return TickSlow }
|
||
|
||
func (*noOpDriver) OnEnter(*Visualizer) {}
|
||
|
||
func (*noOpDriver) OnLeave(*Visualizer) {}
|
||
|
||
func newRenderOnlyDriver(spec VisAnalysisSpec, render func(*Visualizer, []float64) string) func() visModeDriver {
|
||
return func() visModeDriver {
|
||
return &renderOnlyDriver{spec: NormalizeAnalysisSpec(spec), render: render}
|
||
}
|
||
}
|
||
|
||
func newFastRenderOnlyDriver(spec VisAnalysisSpec, tick time.Duration, render func(*Visualizer, []float64) string) func() visModeDriver {
|
||
return func() visModeDriver {
|
||
return &renderOnlyDriver{spec: NormalizeAnalysisSpec(spec), render: render, tickDuration: tick}
|
||
}
|
||
}
|
||
|
||
func newNoOpDriver() visModeDriver {
|
||
return &noOpDriver{}
|
||
}
|
||
|
||
func defaultDriverTick(v *Visualizer, ctx VisTickContext, spec VisAnalysisSpec) {
|
||
if ctx.OverlayActive {
|
||
// Reset both clocks so the first tick after dismissal analyzes
|
||
// immediately and smoothing dt resets to a single-frame step.
|
||
v.lastAnalyzeAt = time.Time{}
|
||
v.lastSmoothTick = time.Time{}
|
||
return
|
||
}
|
||
spec = NormalizeAnalysisSpec(spec)
|
||
if ctx.Analyze != nil {
|
||
// Decouple FFT cadence from animation cadence: skip Analyze if we ran
|
||
// it recently. Animation still advances every tick via advanceSmoothing.
|
||
due := v.lastAnalyzeAt.IsZero() || ctx.Now.IsZero() ||
|
||
ctx.Now.Sub(v.lastAnalyzeAt) >= TickAnalyze
|
||
if due {
|
||
bands := ctx.Analyze(spec)
|
||
if spec.BandCount > 0 {
|
||
v.bands = bands
|
||
}
|
||
if !ctx.Now.IsZero() {
|
||
v.lastAnalyzeAt = ctx.Now
|
||
}
|
||
}
|
||
}
|
||
// Always ease toward the most recent target — even when Analyze is nil
|
||
// or skipped — so animation stays smooth across analysis gaps.
|
||
if spec.BandCount > 0 {
|
||
v.advanceSmoothing(ctx.Now)
|
||
}
|
||
}
|
||
|
||
// defaultDriverTickInterval uses fast ticks only when audio is actively playing
|
||
// with a live visualizer. Paused/stopped playback has no new audio samples, so
|
||
// slow ticks are sufficient and save CPU/GPU repaints. Overlays use slow ticks
|
||
// as well. Bar-style spectrum drivers opt into TickAnim via newFastRenderOnlyDriver.
|
||
func defaultDriverTickInterval(ctx VisTickContext) time.Duration {
|
||
if ctx.OverlayActive {
|
||
return TickSlow
|
||
}
|
||
if ctx.Playing {
|
||
return TickFast
|
||
}
|
||
return TickSlow
|
||
}
|
||
|
||
// Visualizer performs FFT analysis and renders spectrum bars.
|
||
type Visualizer struct {
|
||
prevBySpec map[VisAnalysisSpec][]float64
|
||
edgeCache map[int][]float64
|
||
fftBufCache map[int][]float64
|
||
fftCplxCache map[int][]complex128 // reusable in-place FFT work buffers
|
||
fftTwiddleCache map[int][]complex128 // precomputed roots of unity per FFT size
|
||
windowCache map[int][]float64
|
||
resultBufCache map[VisAnalysisSpec][]float64 // reusable output buffers for Analyze(), keyed by spec
|
||
bands []float64
|
||
smoothedBands []float64 // bands with sub-tick exponential easing toward v.bands
|
||
lastSmoothTick time.Time // wall clock of the last advanceSmoothing call
|
||
lastAnalyzeAt time.Time // wall clock of the last FFT analysis
|
||
sr float64
|
||
Mode VisMode
|
||
Rows int // display height in terminal rows (default 5)
|
||
waveBuf []float64 // raw samples for wave mode
|
||
waveYBuf []int // reusable y-position buffer for wave rendering
|
||
frame uint64 // tick-driven animation clock
|
||
sampleBuf []float64 // reusable buffer for reading audio tap samples
|
||
drivers [VisCount]visModeDriver
|
||
activeMode VisMode
|
||
activeModeSet bool
|
||
refreshPending bool
|
||
luaVisNames []string
|
||
luaRender LuaVisRenderer
|
||
luaDriverCache map[int]visModeDriver
|
||
pulseCoordCache *pulseCoords
|
||
}
|
||
|
||
// LuaVisRenderer is the callback type for rendering a Lua visualizer frame.
|
||
type LuaVisRenderer func(name string, bands [DefaultSpectrumBands]float64, rows, cols int, frame uint64) string
|
||
|
||
// NewVisualizer creates a Visualizer for the given sample rate.
|
||
func NewVisualizer(sampleRate float64) *Visualizer {
|
||
return &Visualizer{
|
||
sr: sampleRate,
|
||
sampleBuf: make([]float64, defaultFFTSize),
|
||
Rows: DefaultVisRows,
|
||
bands: make([]float64, DefaultSpectrumBands),
|
||
prevBySpec: make(map[VisAnalysisSpec][]float64),
|
||
edgeCache: make(map[int][]float64),
|
||
fftBufCache: make(map[int][]float64),
|
||
fftCplxCache: make(map[int][]complex128),
|
||
fftTwiddleCache: make(map[int][]complex128),
|
||
windowCache: make(map[int][]float64),
|
||
resultBufCache: make(map[VisAnalysisSpec][]float64),
|
||
luaDriverCache: make(map[int]visModeDriver),
|
||
refreshPending: true,
|
||
}
|
||
}
|
||
|
||
// CycleMode advances to the next visualizer mode, including Lua visualizers.
|
||
func (v *Visualizer) CycleMode() {
|
||
total := VisCount + VisMode(len(v.luaVisNames))
|
||
v.Mode = (v.Mode + 1) % total
|
||
}
|
||
|
||
// visModes is the single source of truth for all visualizer modes.
|
||
// To add a new mode: add a const, add one line here, create a vis_*.go file.
|
||
var visModes = [VisCount]visEntry{
|
||
VisBars: {"Bars", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderBars)},
|
||
VisBarsDot: {"BarsDot", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderBarsDot)},
|
||
VisRain: {"Rain", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderRain)},
|
||
VisBarsOutline: {"BarsOutline", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderBarsOutline)},
|
||
VisBricks: {"Bricks", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderBricks)},
|
||
VisColumns: {"Columns", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderColumns)},
|
||
VisClassicPeak: {"ClassicPeak", newClassicPeakDriver},
|
||
VisWave: {"Wave", newFastRenderOnlyDriver(spectrumAnalysisSpec(0), TickWave, func(v *Visualizer, _ []float64) string { return v.renderWave() })},
|
||
VisScatter: {"Scatter", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderScatter)},
|
||
VisFlame: {"Flame", newFlameDriver},
|
||
VisRetro: {"Retro", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderRetro)},
|
||
VisPulse: {"Pulse", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderPulse)},
|
||
VisMatrix: {"Matrix", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderMatrix)},
|
||
VisBinary: {"Binary", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBinary)},
|
||
VisSakura: {"Sakura", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderSakura)},
|
||
VisFirework: {"Firework", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFirework)},
|
||
VisBubbles: {"Bubbles", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBubbles)},
|
||
VisLogo: {"Logo", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderLogo)},
|
||
VisTerrain: {"Terrain", newTerrainDriver},
|
||
VisScope: {"Scope", newFastRenderOnlyDriver(spectrumAnalysisSpec(0), TickWave, func(v *Visualizer, _ []float64) string { return v.renderScope() })},
|
||
VisHeartbeat: {"Heartbeat", newFastRenderOnlyDriver(spectrumAnalysisSpec(0), TickWave, func(v *Visualizer, _ []float64) string { return v.renderHeartbeat() })},
|
||
VisButterfly: {"Butterfly", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderButterfly)},
|
||
VisAscii: {"Ascii", newFastRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), TickAnim, (*Visualizer).renderAscii)},
|
||
VisFirefly: {"Firefly", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFirefly)},
|
||
VisMosaic: {"Mosaic", newMosaicDriver},
|
||
VisSand: {"Sand", newSandDriver},
|
||
VisGeyser: {"Geyser", newGeyserDriver},
|
||
VisClassicLED: {"ClassicLED", newClassicLEDDriver},
|
||
VisNone: {"None", newNoOpDriver},
|
||
}
|
||
|
||
var visNameMap map[string]VisMode
|
||
|
||
func init() {
|
||
visNameMap = make(map[string]VisMode, VisCount)
|
||
for i := range VisCount {
|
||
visNameMap[strings.ToLower(visModes[i].name)] = VisMode(i)
|
||
}
|
||
}
|
||
|
||
// ModeName returns the display name of the current mode.
|
||
func (v *Visualizer) ModeName() string {
|
||
if v.Mode < VisCount {
|
||
return visModes[v.Mode].name
|
||
}
|
||
luaIdx := int(v.Mode - VisCount)
|
||
if luaIdx < len(v.luaVisNames) {
|
||
return v.luaVisNames[luaIdx]
|
||
}
|
||
return "Unknown"
|
||
}
|
||
|
||
// StringToVisModeExact converts a name to VisMode, returning false if not found.
|
||
func StringToVisModeExact(name string) (VisMode, bool) {
|
||
mode, ok := visNameMap[strings.ToLower(name)]
|
||
return mode, ok
|
||
}
|
||
|
||
// VisModeNames returns the display names of all built-in visualizer modes.
|
||
func VisModeNames() []string {
|
||
names := make([]string, VisCount)
|
||
for i := range VisCount {
|
||
names[i] = visModes[i].name
|
||
}
|
||
return names
|
||
}
|
||
|
||
// AllModeNames returns the display names of every selectable visualizer mode in
|
||
// cycle order: built-in modes followed by any registered Lua visualizers. The
|
||
// index of each name equals its VisMode value, so a picker can map a list row
|
||
// directly to a mode.
|
||
func (v *Visualizer) AllModeNames() []string {
|
||
return append(VisModeNames(), v.luaVisNames...)
|
||
}
|
||
|
||
// SetMode switches to mode if it is within range (built-in or Lua) and requests
|
||
// a refresh. Out-of-range values are ignored, matching the SetVisualizer guard.
|
||
func (v *Visualizer) SetMode(mode VisMode) {
|
||
if mode < 0 || mode >= VisCount+VisMode(len(v.luaVisNames)) {
|
||
return
|
||
}
|
||
v.Mode = mode
|
||
v.RequestRefresh()
|
||
}
|
||
|
||
func buildSpectrumEdges(count int) []float64 {
|
||
if count <= 0 {
|
||
return nil
|
||
}
|
||
edges := make([]float64, count+1)
|
||
lastAnchor := len(legacySpectrumEdges) - 1
|
||
for i := range count + 1 {
|
||
numerator := i * lastAnchor
|
||
idx := numerator / count
|
||
if idx >= lastAnchor {
|
||
edges[i] = legacySpectrumEdges[lastAnchor]
|
||
continue
|
||
}
|
||
if numerator%count == 0 {
|
||
edges[i] = legacySpectrumEdges[idx]
|
||
continue
|
||
}
|
||
frac := float64(numerator%count) / float64(count)
|
||
lo := legacySpectrumEdges[idx]
|
||
hi := legacySpectrumEdges[idx+1]
|
||
edges[i] = math.Pow(10, math.Log10(lo)*(1-frac)+math.Log10(hi)*frac)
|
||
}
|
||
return edges
|
||
}
|
||
|
||
func buildHannWindow(size int) []float64 {
|
||
window := make([]float64, size)
|
||
for i := range size {
|
||
window[i] = 0.5 * (1 - math.Cos(2*math.Pi*float64(i)/float64(size-1)))
|
||
}
|
||
return window
|
||
}
|
||
|
||
func (v *Visualizer) prevBands(spec VisAnalysisSpec) []float64 {
|
||
if prev, ok := v.prevBySpec[spec]; ok {
|
||
return prev
|
||
}
|
||
prev := make([]float64, spec.BandCount)
|
||
v.prevBySpec[spec] = prev
|
||
return prev
|
||
}
|
||
|
||
func (v *Visualizer) spectrumEdges(count int) []float64 {
|
||
if edges, ok := v.edgeCache[count]; ok {
|
||
return edges
|
||
}
|
||
edges := buildSpectrumEdges(count)
|
||
v.edgeCache[count] = edges
|
||
return edges
|
||
}
|
||
|
||
func (v *Visualizer) fftBuffer(size int) []float64 {
|
||
if buf, ok := v.fftBufCache[size]; ok {
|
||
return buf
|
||
}
|
||
buf := make([]float64, size)
|
||
v.fftBufCache[size] = buf
|
||
return buf
|
||
}
|
||
|
||
func (v *Visualizer) fftComplexBuffer(size int) []complex128 {
|
||
if buf, ok := v.fftCplxCache[size]; ok {
|
||
return buf
|
||
}
|
||
buf := make([]complex128, size)
|
||
v.fftCplxCache[size] = buf
|
||
return buf
|
||
}
|
||
|
||
func (v *Visualizer) fftTwiddles(size int) []complex128 {
|
||
if w, ok := v.fftTwiddleCache[size]; ok {
|
||
return w
|
||
}
|
||
w := buildTwiddles(size)
|
||
v.fftTwiddleCache[size] = w
|
||
return w
|
||
}
|
||
|
||
// resultBufFor returns a reusable []float64 for Analyze output, keyed by the
|
||
// full analysis spec so different specs with the same band count don't alias.
|
||
// Avoids allocating a new slice on every tick (20x/sec).
|
||
func (v *Visualizer) resultBufFor(spec VisAnalysisSpec) []float64 {
|
||
if buf, ok := v.resultBufCache[spec]; ok {
|
||
clear(buf)
|
||
return buf
|
||
}
|
||
buf := make([]float64, spec.BandCount)
|
||
v.resultBufCache[spec] = buf
|
||
return buf
|
||
}
|
||
|
||
func (v *Visualizer) hannWindow(size int) []float64 {
|
||
if window, ok := v.windowCache[size]; ok {
|
||
return window
|
||
}
|
||
window := buildHannWindow(size)
|
||
v.windowCache[size] = window
|
||
return window
|
||
}
|
||
|
||
func (v *Visualizer) resetSpectrumHistory() {
|
||
if v == nil {
|
||
return
|
||
}
|
||
clear(v.prevBySpec)
|
||
}
|
||
|
||
func (v *Visualizer) EnsureSampleBuf(size int) []float64 {
|
||
size = NormalizeAnalysisSpec(VisAnalysisSpec{FFTSize: size}).FFTSize
|
||
if cap(v.sampleBuf) < size {
|
||
v.sampleBuf = make([]float64, size)
|
||
} else {
|
||
v.sampleBuf = v.sampleBuf[:size]
|
||
}
|
||
return v.sampleBuf
|
||
}
|
||
|
||
// RegisterLuaVisualizers adds Lua visualizer names so they can be cycled
|
||
// through with the v key. renderer is called when a Lua visualizer is active.
|
||
func (v *Visualizer) RegisterLuaVisualizers(names []string, renderer LuaVisRenderer) {
|
||
v.luaVisNames = names
|
||
v.luaRender = renderer
|
||
clear(v.luaDriverCache)
|
||
// Add to name map for StringToVisModeExact lookups.
|
||
for i, name := range names {
|
||
visNameMap[strings.ToLower(name)] = VisCount + VisMode(i)
|
||
}
|
||
}
|
||
|
||
// Analyze runs FFT on raw audio samples and returns normalized band levels (0-1).
|
||
func (v *Visualizer) Analyze(samples []float64, spec VisAnalysisSpec) []float64 {
|
||
spec = NormalizeAnalysisSpec(spec)
|
||
|
||
// Store raw samples for wave mode.
|
||
if n := len(samples); n > 0 {
|
||
if cap(v.waveBuf) >= n {
|
||
v.waveBuf = v.waveBuf[:n]
|
||
} else {
|
||
v.waveBuf = make([]float64, n)
|
||
}
|
||
copy(v.waveBuf, samples)
|
||
} else {
|
||
v.waveBuf = v.waveBuf[:0]
|
||
}
|
||
|
||
if spec.BandCount <= 0 {
|
||
return nil
|
||
}
|
||
|
||
prev := v.prevBands(spec)
|
||
bands := v.resultBufFor(spec)
|
||
|
||
// Silence gate: skip the FFT pipeline when input is empty or effectively
|
||
// silent. A quick max-abs scan is two orders of magnitude cheaper than the
|
||
// FFT and fires whenever playback is paused, between tracks, or quiet.
|
||
silent := len(samples) == 0
|
||
if !silent {
|
||
maxAbs := 0.0
|
||
for _, s := range samples {
|
||
a := math.Abs(s)
|
||
if a > maxAbs {
|
||
maxAbs = a
|
||
}
|
||
}
|
||
silent = maxAbs < 1e-5
|
||
}
|
||
if silent {
|
||
for b := range spec.BandCount {
|
||
bands[b] = prev[b] * 0.8
|
||
prev[b] = bands[b]
|
||
}
|
||
return bands
|
||
}
|
||
|
||
// Window samples into the reusable complex FFT buffer. Any tail beyond the
|
||
// provided samples stays zero from the previous run-through — we always
|
||
// overwrite the first `have` entries and explicitly zero the rest below.
|
||
cbuf := v.fftComplexBuffer(spec.FFTSize)
|
||
window := v.hannWindow(spec.FFTSize)
|
||
have := min(len(samples), spec.FFTSize)
|
||
for i := range have {
|
||
cbuf[i] = complex(samples[i]*window[i], 0)
|
||
}
|
||
for i := have; i < spec.FFTSize; i++ {
|
||
cbuf[i] = 0
|
||
}
|
||
|
||
fftInPlace(cbuf, v.fftTwiddles(spec.FFTSize))
|
||
|
||
// Power spectrum |X|^2 into the reusable float buffer. Skipping the sqrt
|
||
// per bin halves the work compared to magnitudes; the log10 below absorbs
|
||
// the factor of two so band values stay in the same [0,1] range.
|
||
halfLen := spec.FFTSize / 2
|
||
powers := v.fftBuffer(spec.FFTSize)[:halfLen]
|
||
powers[0] = 0
|
||
for i := 1; i < halfLen; i++ {
|
||
re := real(cbuf[i])
|
||
im := imag(cbuf[i])
|
||
powers[i] = re*re + im*im
|
||
}
|
||
|
||
binHz := v.sr / float64(spec.FFTSize)
|
||
edges := v.spectrumEdges(spec.BandCount)
|
||
|
||
for b := range spec.BandCount {
|
||
sum := averageSpectrumRangeLinear(powers, edges[b]/binHz, edges[b+1]/binHz)
|
||
|
||
// Convert to dB-like scale. 10*log10(power) == 20*log10(magnitude).
|
||
if sum > 0 {
|
||
bands[b] = (10*math.Log10(sum) + 10) / 50
|
||
}
|
||
bands[b] = max(0, min(1, bands[b]))
|
||
|
||
// Temporal smoothing: fast attack, slow decay.
|
||
if bands[b] > prev[b] {
|
||
bands[b] = bands[b]*0.6 + prev[b]*0.4
|
||
} else {
|
||
bands[b] = bands[b]*0.25 + prev[b]*0.75
|
||
}
|
||
prev[b] = bands[b]
|
||
}
|
||
|
||
return bands
|
||
}
|
||
|
||
// Render dispatches to the active visualizer mode.
|
||
func (v *Visualizer) Render() string {
|
||
driver := v.syncDriverMode()
|
||
if driver == nil {
|
||
return ""
|
||
}
|
||
return driver.Render(v)
|
||
}
|
||
|
||
func (v *Visualizer) RequestRefresh() {
|
||
if v != nil {
|
||
v.refreshPending = true
|
||
}
|
||
}
|
||
|
||
func (v *Visualizer) ConsumeRefresh() bool {
|
||
if v == nil || !v.refreshPending {
|
||
return false
|
||
}
|
||
v.refreshPending = false
|
||
return true
|
||
}
|
||
|
||
// SampleBuf returns the internal sample buffer (for slicing after SamplesInto).
|
||
func (v *Visualizer) SampleBuf() []float64 { return v.sampleBuf }
|
||
|
||
// Bands returns the current spectrum band values.
|
||
func (v *Visualizer) Bands() []float64 { return v.bands }
|
||
|
||
// SmoothedBands returns the eased per-frame band values used by spectrum
|
||
// renderers. Falls back to the raw bands until smoothing has run at least
|
||
// once.
|
||
func (v *Visualizer) SmoothedBands() []float64 {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
if len(v.smoothedBands) == len(v.bands) && len(v.smoothedBands) > 0 {
|
||
return v.smoothedBands
|
||
}
|
||
return v.bands
|
||
}
|
||
|
||
// advanceSmoothing eases v.smoothedBands toward v.bands using the same
|
||
// fast-attack / slow-decay shape as classicPeak's per-bar smoothing
|
||
// (classicPeakStep), so every spectrum visualizer glides between FFT samples
|
||
// instead of snapping at the analysis rate.
|
||
func (v *Visualizer) advanceSmoothing(now time.Time) {
|
||
if v == nil || len(v.bands) == 0 {
|
||
return
|
||
}
|
||
if len(v.smoothedBands) != len(v.bands) {
|
||
// First frame after a spec change snaps to the current analysis output
|
||
// so existing levels appear immediately instead of fading in from zero.
|
||
v.smoothedBands = append(v.smoothedBands[:0], v.bands...)
|
||
v.lastSmoothTick = now
|
||
return
|
||
}
|
||
dt := TickAnim.Seconds()
|
||
if !now.IsZero() && !v.lastSmoothTick.IsZero() {
|
||
dt = now.Sub(v.lastSmoothTick).Seconds()
|
||
}
|
||
if dt <= 0 || dt > maxSmoothDtFrames*TickAnim.Seconds() {
|
||
dt = TickAnim.Seconds()
|
||
}
|
||
v.lastSmoothTick = now
|
||
for i, target := range v.bands {
|
||
v.smoothedBands[i] = classicPeakStep(v.smoothedBands[i], target, dt)
|
||
}
|
||
}
|
||
|
||
// Frame returns the current animation frame counter.
|
||
func (v *Visualizer) Frame() uint64 { return v.frame }
|
||
|
||
// RefreshPending reports whether a refresh has been requested.
|
||
func (v *Visualizer) RefreshPending() bool { return v != nil && v.refreshPending }
|
||
|
||
func (v *Visualizer) TickInterval(ctx VisTickContext) time.Duration {
|
||
driver := v.syncDriverMode()
|
||
if driver == nil {
|
||
return TickSlow
|
||
}
|
||
if ctx.Paused {
|
||
return TickSlow
|
||
}
|
||
return driver.TickInterval(v, ctx)
|
||
}
|
||
|
||
func (v *Visualizer) Tick(ctx VisTickContext) {
|
||
driver := v.syncDriverMode()
|
||
if driver == nil {
|
||
return
|
||
}
|
||
v.refreshPending = false
|
||
if ctx.Paused {
|
||
return
|
||
}
|
||
if v.Mode != VisNone && !ctx.OverlayActive {
|
||
v.frame++
|
||
}
|
||
driver.Tick(v, ctx)
|
||
}
|
||
|
||
func (v *Visualizer) driverFor(mode VisMode) visModeDriver {
|
||
if v == nil || mode < 0 {
|
||
return nil
|
||
}
|
||
if mode >= VisCount {
|
||
idx := int(mode - VisCount)
|
||
if idx < 0 || idx >= len(v.luaVisNames) {
|
||
return nil
|
||
}
|
||
if driver, ok := v.luaDriverCache[idx]; ok {
|
||
return driver
|
||
}
|
||
driver := &luaModeDriver{index: idx}
|
||
v.luaDriverCache[idx] = driver
|
||
return driver
|
||
}
|
||
if v.drivers[mode] == nil {
|
||
newDriver := visModes[mode].newDriver
|
||
if newDriver == nil {
|
||
return nil
|
||
}
|
||
v.drivers[mode] = newDriver()
|
||
}
|
||
return v.drivers[mode]
|
||
}
|
||
|
||
type luaModeDriver struct {
|
||
index int
|
||
}
|
||
|
||
func (*luaModeDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
|
||
return spectrumAnalysisSpec(DefaultSpectrumBands)
|
||
}
|
||
|
||
func (d *luaModeDriver) Render(v *Visualizer) string {
|
||
if v == nil || d.index < 0 || d.index >= len(v.luaVisNames) || v.luaRender == nil {
|
||
return ""
|
||
}
|
||
return v.luaRender(v.luaVisNames[d.index], luaBands(v.bands), v.Rows, PanelWidth, v.frame)
|
||
}
|
||
|
||
func (d *luaModeDriver) Tick(v *Visualizer, ctx VisTickContext) {
|
||
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
|
||
}
|
||
|
||
func (*luaModeDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
|
||
return defaultDriverTickInterval(ctx)
|
||
}
|
||
|
||
func (*luaModeDriver) OnEnter(*Visualizer) {}
|
||
|
||
func (*luaModeDriver) OnLeave(*Visualizer) {}
|
||
|
||
func luaBands(src []float64) [DefaultSpectrumBands]float64 {
|
||
var bands [DefaultSpectrumBands]float64
|
||
copy(bands[:], src)
|
||
return bands
|
||
}
|
||
|
||
func (v *Visualizer) syncDriverMode() visModeDriver {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
driver := v.driverFor(v.Mode)
|
||
if !v.activeModeSet {
|
||
if driver != nil {
|
||
driver.OnEnter(v)
|
||
}
|
||
v.activeMode = v.Mode
|
||
v.activeModeSet = true
|
||
return driver
|
||
}
|
||
if v.activeMode != v.Mode {
|
||
prev := v.driverFor(v.activeMode)
|
||
prevSpec := VisAnalysisSpec{}
|
||
if prev != nil {
|
||
prevSpec = NormalizeAnalysisSpec(prev.AnalysisSpec(v))
|
||
}
|
||
nextSpec := VisAnalysisSpec{}
|
||
if driver != nil {
|
||
nextSpec = NormalizeAnalysisSpec(driver.AnalysisSpec(v))
|
||
}
|
||
if (prevSpec.BandCount == 0) != (nextSpec.BandCount == 0) {
|
||
v.resetSpectrumHistory()
|
||
}
|
||
if prev != nil {
|
||
prev.OnLeave(v)
|
||
}
|
||
if driver != nil {
|
||
driver.OnEnter(v)
|
||
}
|
||
v.activeMode = v.Mode
|
||
}
|
||
return driver
|
||
}
|
||
|
||
// fracBlock returns the fractional Unicode block character for a band level
|
||
// within the row span [rowBottom, rowTop]. Used by bars and columns visualizers.
|
||
func fracBlock(level, rowBottom, rowTop float64) string {
|
||
if level >= rowTop {
|
||
return "█"
|
||
}
|
||
if level > rowBottom {
|
||
frac := (level - rowBottom) / (rowTop - rowBottom)
|
||
idx := int(frac * float64(len(barBlocks)-1))
|
||
idx = max(0, min(idx, len(barBlocks)-1))
|
||
return barBlocks[idx]
|
||
}
|
||
return " "
|
||
}
|
||
|
||
// scatterHash returns a pseudo-random value in [0, 1) for a given dot position
|
||
// and frame. Dots persist for a few frames to create a twinkling effect.
|
||
func scatterHash(band, row, col int, frame uint64) float64 {
|
||
// Stagger per-dot so they don't all change simultaneously.
|
||
f := (frame + uint64(row*3+col)) / 3
|
||
h := uint64(band)*7919 + uint64(row)*6271 + uint64(col)*3037 + f*104729
|
||
h ^= h >> 16
|
||
h *= 0x45d9f3b37197344b
|
||
h ^= h >> 16
|
||
return float64(h%10000) / 10000.0
|
||
}
|
||
|
||
// specTag returns 0, 1, or 2 identifying the spectrum color tier for style-run
|
||
// batching, using the same thresholds as specWrap.
|
||
func specTag(norm float64) int {
|
||
if norm >= 0.6 {
|
||
return 2
|
||
}
|
||
if norm >= 0.3 {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// specWrap wraps body in the cached ANSI sequences for the spectrum color at
|
||
// the given row-bottom (0-1). One string concatenation instead of the several
|
||
// allocations a per-call lipgloss.Style.Render would perform.
|
||
func specWrap(rowBottom float64, body string) string {
|
||
var prefix, suffix string
|
||
switch specTag(rowBottom) {
|
||
case 2:
|
||
prefix, suffix = specHighPrefix, specHighSuffix
|
||
case 1:
|
||
prefix, suffix = specMidPrefix, specMidSuffix
|
||
case 0:
|
||
prefix, suffix = specLowPrefix, specLowSuffix
|
||
}
|
||
if prefix == "" {
|
||
return body
|
||
}
|
||
return prefix + body + suffix
|
||
}
|
||
|
||
// flushStyleRun appends the accumulated run bytes to sb wrapped in the cached
|
||
// ANSI sequences for the given tag, then resets run. Tag -1 writes unstyled.
|
||
// Streaming via the pre-extracted prefix/suffix strings avoids allocating a
|
||
// fresh lipgloss.Render result on every flush (the hot path for Matrix/Pulse).
|
||
func flushStyleRun(sb *strings.Builder, run *strings.Builder, tag int) {
|
||
if run.Len() == 0 {
|
||
return
|
||
}
|
||
var prefix, suffix string
|
||
switch tag {
|
||
case 2:
|
||
prefix, suffix = specHighPrefix, specHighSuffix
|
||
case 1:
|
||
prefix, suffix = specMidPrefix, specMidSuffix
|
||
case 0:
|
||
prefix, suffix = specLowPrefix, specLowSuffix
|
||
}
|
||
if prefix != "" {
|
||
sb.WriteString(prefix)
|
||
}
|
||
// run.String() aliases the builder's backing array (no allocation) and we
|
||
// copy those bytes into sb before run.Reset() releases the slice.
|
||
sb.WriteString(run.String())
|
||
if suffix != "" {
|
||
sb.WriteString(suffix)
|
||
}
|
||
run.Reset()
|
||
}
|