Skip to content

Commit 3cabb32

Browse files
committed
Add visualizer
1 parent c1db5c9 commit 3cabb32

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ eq_preset = "Flat"
3636
eq = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
3737

3838
# Visualizer mode (leave empty for default Bars)
39-
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Logo, Terrain, Glitch, Scope, Heartbeat, Butterfly, Lightning, None
39+
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Bubbles, Logo, Terrain, Glitch, Scope, Heartbeat, Butterfly, Lightning, None
4040
visualizer = "Bars"
4141

4242
# Compact mode: cap UI width at 80 columns (default: fluid/full-width)

ui/vis_bubbles.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package ui
2+
3+
import (
4+
"math"
5+
"strings"
6+
)
7+
8+
// renderBubbles draws rising air bubbles using Braille dots. Each bubble is a
9+
// hollow ring with a tiny specular highlight that drifts upward and sways
10+
// laterally. Audio energy modulates the sway and highlight intensity; the
11+
// bubble count is fixed so bubbles never pop in or out of existence mid-air.
12+
// Bubbles fade stochastically as they approach the surface so they appear to
13+
// "pop."
14+
func (v *Visualizer) renderBubbles(bands []float64) string {
15+
height := v.Rows
16+
dotRows := height * 4
17+
dotCols := PanelWidth * 2
18+
19+
grid := make([]bool, dotRows*dotCols)
20+
21+
var totalEnergy float64
22+
for _, e := range bands {
23+
totalEnergy += e
24+
}
25+
avgEnergy := totalEnergy / float64(len(bands))
26+
27+
// Fixed count — changing this per frame makes bubbles spawn/vanish mid-air.
28+
const numBubbles = 18
29+
30+
for i := range numBubbles {
31+
seed := uint64(i)*104729 + 7919
32+
33+
// Stable per-bubble radius (1.5 to 4.0 dots). Must not depend on
34+
// per-frame audio, otherwise trajectory parameters derived from it
35+
// (speedDiv, wrapH, baseY) jitter every frame and the bubble flashes
36+
// around the screen instead of rising smoothly.
37+
radius := 1.5 + float64(seed%100)/100.0*2.5
38+
39+
// Bigger bubbles rise slower (buoyancy feels floaty). Lower divisor =
40+
// faster rise: at 20 FPS a divisor of ~4 means one dot every ~200ms,
41+
// crossing the panel in roughly 4 seconds.
42+
speedDiv := 3 + int(radius)
43+
44+
// Continuous upward scroll with off-screen buffer for smooth entry/exit.
45+
wrapH := dotRows + int(radius*2) + 8
46+
baseY := int((seed * 3037) % uint64(wrapH))
47+
y := wrapH - 1 - ((baseY+int(v.frame)/speedDiv)%wrapH) - int(radius) - 2
48+
49+
// Horizontal position with gentle sinusoidal sway. Amplitude scales
50+
// with overall energy — quiet passages drift calmly, loud passages
51+
// wobble a bit more. This only shifts x, so it can't destabilize the
52+
// trajectory.
53+
baseX := int(seed % uint64(dotCols))
54+
swayPhase := float64(seed%1000) / 1000.0 * 2 * math.Pi
55+
swayAmp := 1.5 + avgEnergy*2.5
56+
sway := math.Sin(float64(v.frame)*0.03+swayPhase) * swayAmp
57+
x := baseX + int(sway)
58+
59+
// Pop fade — the last few rows thin the ring stochastically.
60+
popZone := int(radius) + 3
61+
popFade := 1.0
62+
if y < popZone {
63+
popFade = math.Max(0, float64(y)/float64(popZone))
64+
}
65+
66+
// Draw hollow ring.
67+
rInner := radius - 0.9
68+
bbox := int(radius) + 1
69+
for dy := -bbox; dy <= bbox; dy++ {
70+
for dx := -bbox; dx <= bbox; dx++ {
71+
dist := math.Sqrt(float64(dx*dx + dy*dy))
72+
if dist > radius || dist < rInner {
73+
continue
74+
}
75+
// Stable per-bubble pop pattern (no frame dependency) so the
76+
// ring doesn't strobe as it fades near the top.
77+
if popFade < 1.0 && scatterHash(i, dy, dx, 0) > popFade {
78+
continue
79+
}
80+
gy := y + dy
81+
gx := x + dx
82+
if gy >= 0 && gy < dotRows && gx >= 0 && gx < dotCols {
83+
grid[gy*dotCols+gx] = true
84+
}
85+
}
86+
}
87+
88+
// Specular highlight — small bright cluster in the upper-left quadrant.
89+
if radius >= 2.0 && popFade > 0.5 {
90+
hx := x - int(radius*0.45)
91+
hy := y - int(radius*0.45)
92+
for _, d := range [][2]int{{0, 0}, {0, 1}, {1, 0}} {
93+
gy := hy + d[0]
94+
gx := hx + d[1]
95+
if gy >= 0 && gy < dotRows && gx >= 0 && gx < dotCols {
96+
grid[gy*dotCols+gx] = true
97+
}
98+
}
99+
}
100+
}
101+
102+
lines := make([]string, height)
103+
for row := range height {
104+
var content strings.Builder
105+
for ch := range PanelWidth {
106+
var braille rune = '\u2800'
107+
for dr := range 4 {
108+
for dc := range 2 {
109+
if grid[(row*4+dr)*dotCols+ch*2+dc] {
110+
braille |= brailleBit[dr][dc]
111+
}
112+
}
113+
}
114+
content.WriteRune(braille)
115+
}
116+
// Top rows warm (light through surface), bottom rows cool (depth).
117+
lines[row] = specStyle(float64(height-1-row) / float64(height)).Render(content.String())
118+
}
119+
120+
return strings.Join(lines, "\n")
121+
}

ui/visualizer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ const (
5252
VisBinary // streaming binary 0s and 1s
5353
VisSakura // falling cherry blossom petals
5454
VisFirework // exploding firework bursts
55+
VisBubbles // rising hollow ring bubbles
5556
VisLogo // CLIAMP pixel text
5657
VisTerrain // scrolling side-view mountain range
5758
VisScope // Lissajous XY oscilloscope
@@ -382,6 +383,7 @@ var visModes = [VisCount]visEntry{
382383
VisBinary: {"Binary", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBinary)},
383384
VisSakura: {"Sakura", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderSakura)},
384385
VisFirework: {"Firework", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFirework)},
386+
VisBubbles: {"Bubbles", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderBubbles)},
385387
VisLogo: {"Logo", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderLogo)},
386388
VisTerrain: {"Terrain", newTerrainDriver},
387389
VisScope: {"Scope", newFastRenderOnlyDriver(spectrumAnalysisSpec(0), TickWave, func(v *Visualizer, _ []float64) string { return v.renderScope() })},

0 commit comments

Comments
 (0)