Skip to content

Commit 2d74cde

Browse files
committed
Add Geyser visualizer and tune Sand bass sensitivity
Geyser is a particle fountain rooted at the bottom of the panel: bass transients eject thick jets that arc back under gravity, sustained loud passages keep a steady column going, and quiet sections drip. Particles inherit a tier from the band that produced them, so heavy bass paints the spray red. Sand's bass triggers were also tightened so gentler kicks register — transient + sustained thresholds dropped, and the explosion gate fires on a smaller fill so it actually triggers during normal listening. Also factor out a shared Braille-grid rasteriser used by Geyser (and ready for any future visualizer drawing to a 4x2 dot subgrid).
1 parent 2b0a904 commit 2d74cde

5 files changed

Lines changed: 261 additions & 7 deletions

File tree

docs/configuration.md

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

4646
# Visualizer mode (leave empty for default Bars)
47-
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Bubbles, Logo, Terrain, Scope, Heartbeat, Butterfly, Ascii, Firefly, Mosaic, Sand, None
47+
# Options: Bars, BarsDot, Rain, BarsOutline, Bricks, Columns, ClassicPeak, Wave, Scatter, Flame, Retro, Pulse, Matrix, Binary, Sakura, Firework, Bubbles, Logo, Terrain, Scope, Heartbeat, Butterfly, Ascii, Firefly, Mosaic, Sand, Geyser, None
4848
visualizer = "Bars"
4949

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

ui/vis_braillegrid.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package ui
2+
3+
import "strings"
4+
5+
// brailleGrid is a 4×2 dot-per-cell rasteriser shared by visualizers that draw
6+
// to a fine subgrid (sand, speaker, lightning, crack, quake, geyser, strings).
7+
// Each cell stores a tier (1..3 = low/mid/high colour, 0 = empty) and the
8+
// renderer composes one Braille glyph per character cell.
9+
type brailleGrid struct {
10+
cells []int8
11+
dotRows int
12+
dotCols int
13+
}
14+
15+
func (g *brailleGrid) ensure(rows, cols int) {
16+
if rows == g.dotRows && cols == g.dotCols && len(g.cells) == rows*cols {
17+
for i := range g.cells {
18+
g.cells[i] = 0
19+
}
20+
return
21+
}
22+
g.cells = make([]int8, rows*cols)
23+
g.dotRows = rows
24+
g.dotCols = cols
25+
}
26+
27+
func (g *brailleGrid) clear() {
28+
for i := range g.cells {
29+
g.cells[i] = 0
30+
}
31+
}
32+
33+
func (g *brailleGrid) set(x, y int, tier int8) {
34+
if x < 0 || x >= g.dotCols || y < 0 || y >= g.dotRows {
35+
return
36+
}
37+
if tier > g.cells[y*g.dotCols+x] {
38+
g.cells[y*g.dotCols+x] = tier
39+
}
40+
}
41+
42+
// render flattens the dot grid to len(rows) lines, packing 4×2 dot blocks into
43+
// Braille glyphs and emitting tier-coloured runs.
44+
func (g *brailleGrid) render(rows int) string {
45+
if g.dotRows < rows*4 || g.dotCols < PanelWidth*2 {
46+
return strings.Repeat("\n", max(0, rows-1))
47+
}
48+
lines := make([]string, rows)
49+
for row := 0; row < rows; row++ {
50+
var sb, run strings.Builder
51+
tag := -1
52+
for col := 0; col < PanelWidth; col++ {
53+
var braille rune = '⠀'
54+
cellTag := -1
55+
for dr := 0; dr < 4; dr++ {
56+
for dc := 0; dc < 2; dc++ {
57+
y := row*4 + dr
58+
x := col*2 + dc
59+
t := g.cells[y*g.dotCols+x]
60+
if t == 0 {
61+
continue
62+
}
63+
braille |= brailleBit[dr][dc]
64+
if int(t)-1 > cellTag {
65+
cellTag = int(t) - 1
66+
}
67+
}
68+
}
69+
if cellTag < 0 {
70+
cellTag = 0
71+
}
72+
if cellTag != tag {
73+
flushStyleRun(&sb, &run, tag)
74+
tag = cellTag
75+
}
76+
run.WriteRune(braille)
77+
}
78+
flushStyleRun(&sb, &run, tag)
79+
lines[row] = sb.String()
80+
}
81+
return strings.Join(lines, "\n")
82+
}
83+
84+
// drawLine plots a Bresenham line into the grid at the given tier.
85+
func (g *brailleGrid) drawLine(x0, y0, x1, y1 int, tier int8) {
86+
dx := x1 - x0
87+
if dx < 0 {
88+
dx = -dx
89+
}
90+
dy := -(y1 - y0)
91+
if dy > 0 {
92+
dy = -dy
93+
}
94+
sx := 1
95+
if x0 >= x1 {
96+
sx = -1
97+
}
98+
sy := 1
99+
if y0 >= y1 {
100+
sy = -1
101+
}
102+
err := dx + dy
103+
for {
104+
g.set(x0, y0, tier)
105+
if x0 == x1 && y0 == y1 {
106+
return
107+
}
108+
e2 := 2 * err
109+
if e2 >= dy {
110+
err += dy
111+
x0 += sx
112+
}
113+
if e2 <= dx {
114+
err += dx
115+
y0 += sy
116+
}
117+
}
118+
}
119+
120+
// rng64 advances a 64-bit LCG and returns a [0,1) double.
121+
func rng64(state *uint64) float64 {
122+
*state = *state*6364136223846793005 + 1442695040888963407
123+
return float64((*state>>33)%1000) / 1000.0
124+
}

ui/vis_geyser.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package ui
2+
3+
import "time"
4+
5+
// geyserDriver draws a particle fountain rooted at the bottom of the panel.
6+
// Sustained loudness keeps a steady column of mist, bass transients launch
7+
// strong vertical jets, and every particle then arcs back down under gravity
8+
// with a touch of lateral spray. Particles inherit a tier from the band that
9+
// produced them, so dense bass passages paint the column red and treble
10+
// embellishments add green sparkles to the canopy.
11+
type geyserDriver struct {
12+
grid brailleGrid
13+
particles []geyserParticle
14+
rng uint64
15+
prevBass float64
16+
}
17+
18+
type geyserParticle struct {
19+
x, y float64
20+
vx, vy float64
21+
tier int8
22+
life int
23+
}
24+
25+
func newGeyserDriver() visModeDriver { return &geyserDriver{rng: 0xFEED5EED} }
26+
27+
func (*geyserDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
28+
return spectrumAnalysisSpec(DefaultSpectrumBands)
29+
}
30+
31+
func (d *geyserDriver) Tick(v *Visualizer, ctx VisTickContext) {
32+
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
33+
if ctx.OverlayActive {
34+
return
35+
}
36+
dotRows, dotCols := v.Rows*4, PanelWidth*2
37+
if dotRows < 4 || dotCols < 4 {
38+
return
39+
}
40+
d.grid.ensure(dotRows, dotCols)
41+
d.grid.clear()
42+
43+
bands := v.SmoothedBands()
44+
if len(bands) == 0 {
45+
return
46+
}
47+
bass := bandAvg(bands, 0, max(1, len(bands)/3))
48+
mid := bandAvg(bands, len(bands)/3, 2*len(bands)/3)
49+
high := bandAvg(bands, 2*len(bands)/3, len(bands))
50+
delta := bass - d.prevBass
51+
d.prevBass = bass
52+
53+
jetX := dotCols / 2
54+
jetSpread := max(2, dotCols/16)
55+
56+
// Steady drizzle: spawn rate scales with overall loudness so quiet passages
57+
// idle a thin trickle and loud passages keep a column going. Bass weights
58+
// most heavily so a heavy bassline alone keeps the column flowing.
59+
steady := bass*0.85 + mid*0.25 + high*0.08
60+
for i := 0; i < int(steady*6); i++ {
61+
d.spawn(jetX, dotRows-1, jetSpread, 1.5+steady*4.5, &bass, &mid, &high)
62+
}
63+
64+
// Transient kick: shoot a thick burst. Triggers on smaller deltas now so
65+
// even gentler kick drums register.
66+
if delta > 0.06 && bass > 0.15 {
67+
burst := 40 + int(delta*180)
68+
for i := 0; i < burst; i++ {
69+
d.spawn(jetX, dotRows-1, jetSpread*2, 4.5+delta*10.0+bass*4.0, &bass, &mid, &high)
70+
}
71+
}
72+
73+
// Advance particles.
74+
const gravity = 0.30
75+
const drag = 0.992
76+
live := d.particles[:0]
77+
for _, p := range d.particles {
78+
p.vy += gravity
79+
p.vx *= drag
80+
p.x += p.vx
81+
p.y += p.vy
82+
p.life++
83+
ix, iy := int(p.x), int(p.y)
84+
if iy >= dotRows || ix < 0 || ix >= dotCols || p.life > 200 {
85+
continue
86+
}
87+
if iy < 0 {
88+
iy = 0
89+
}
90+
d.grid.set(ix, iy, p.tier)
91+
live = append(live, p)
92+
}
93+
d.particles = live
94+
}
95+
96+
func (d *geyserDriver) spawn(x, y, spread int, vy float64, bass, mid, high *float64) {
97+
jx := x + int(rng64(&d.rng)*float64(2*spread+1)) - spread
98+
vyJitter := vy * (0.6 + rng64(&d.rng)*0.5)
99+
vxJitter := (rng64(&d.rng) - 0.5) * (1.0 + vy*0.4)
100+
r := rng64(&d.rng)
101+
var tier int8 = 1
102+
switch {
103+
case r < *bass:
104+
tier = 3
105+
case r < *bass+*mid:
106+
tier = 2
107+
default:
108+
_ = high
109+
}
110+
d.particles = append(d.particles, geyserParticle{
111+
x: float64(jx), y: float64(y),
112+
vx: vxJitter, vy: -vyJitter,
113+
tier: tier,
114+
})
115+
}
116+
117+
func (*geyserDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
118+
return defaultDriverTickInterval(ctx)
119+
}
120+
func (d *geyserDriver) OnEnter(*Visualizer) {
121+
d.grid = brailleGrid{}
122+
d.particles = nil
123+
d.prevBass = 0
124+
}
125+
func (*geyserDriver) OnLeave(*Visualizer) {}
126+
func (d *geyserDriver) Render(v *Visualizer) string {
127+
return d.grid.render(v.Rows)
128+
}

ui/vis_sand.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,14 @@ func (d *sandDriver) Tick(v *Visualizer, ctx VisTickContext) {
142142

143143
// 0. Explosion check: fires before the normal bump branches so the
144144
// grid is cleared *instead* of being merely shaken when overfilled.
145-
if delta > 0.10 && bass > 0.25 {
145+
if delta > 0.06 && bass > 0.15 {
146146
fill := 0
147147
for _, g := range d.grid {
148148
if g != 0 {
149149
fill++
150150
}
151151
}
152-
if float64(fill)/float64(len(d.grid)) > 0.40 {
152+
if float64(fill)/float64(len(d.grid)) > 0.30 {
153153
// Convert every grain into a ballistic particle and enter the
154154
// explosion phase. The simulation will animate the burst over
155155
// the next few dozen frames, then resume.
@@ -159,8 +159,8 @@ func (d *sandDriver) Tick(v *Visualizer, ctx VisTickContext) {
159159
}
160160

161161
// 1. Transient bump.
162-
if delta > 0.10 && bass > 0.25 {
163-
strength := delta*2.5 + bass*0.6
162+
if delta > 0.06 && bass > 0.15 {
163+
strength := delta*3.5 + bass*0.8
164164
if strength > 1.4 {
165165
strength = 1.4
166166
}
@@ -211,9 +211,9 @@ func (d *sandDriver) Tick(v *Visualizer, ctx VisTickContext) {
211211
// 2. Sustained rumble — applies whenever bass is high, regardless of
212212
// transient. Smaller per-grain motion but applied every frame, so the bed
213213
// keeps churning during a held kick.
214-
if bass > 0.45 {
214+
if bass > 0.30 {
215215
// Strength climbs with how far above the threshold we are.
216-
rumble := (bass - 0.45) * 1.6
216+
rumble := (bass - 0.30) * 1.8
217217
if rumble > 0.6 {
218218
rumble = 0.6
219219
}

ui/visualizer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const (
6363
VisFirefly // firefly meadow at dusk
6464
VisMosaic // static heatmap of flickering tiles
6565
VisSand // falling-sand cellular automaton
66+
VisGeyser // bass-driven particle fountain
6667
VisNone // hidden — no visualizer
6768
VisCount // sentinel for cycling
6869
)
@@ -460,6 +461,7 @@ var visModes = [VisCount]visEntry{
460461
VisFirefly: {"Firefly", newRenderOnlyDriver(spectrumAnalysisSpec(DefaultSpectrumBands), (*Visualizer).renderFirefly)},
461462
VisMosaic: {"Mosaic", newMosaicDriver},
462463
VisSand: {"Sand", newSandDriver},
464+
VisGeyser: {"Geyser", newGeyserDriver},
463465
VisNone: {"None", newNoOpDriver},
464466
}
465467

0 commit comments

Comments
 (0)