Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

1033 lines
31 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}