项目文件夹

文件
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 行
31 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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()
}