package ui import ( "math" "strings" ) // pulseCoords holds per-dot distance-from-center and angle values for the // current panel dimensions. Recomputed lazily on resize so the hot render // loop can skip ~3360 sqrt and atan2 calls per frame (5 rows × 84 cols × 8 // dots) and read from flat arrays instead. type pulseCoords struct { width, height int maxR float64 dist []float64 angle []float64 } func (v *Visualizer) pulseCoords() *pulseCoords { height := v.Rows width := PanelWidth if c := v.pulseCoordCache; c != nil && c.width == width && c.height == height { return c } dotRows := height * 4 dotCols := width * 2 centerX := float64(dotCols) / 2.0 centerY := float64(dotRows) / 2.0 xScale := centerY / centerX size := height * width * 8 c := &pulseCoords{ width: width, height: height, maxR: centerY - 1, dist: make([]float64, size), angle: make([]float64, size), } for row := range height { for col := range width { for dr := range 4 { for dc := range 2 { dx := (float64(col*2+dc) - centerX) * xScale dy := float64(row*4+dr) - centerY idx := pulseDotIndex(row, col, dr, dc, width) c.dist[idx] = math.Sqrt(dx*dx + dy*dy) a := math.Atan2(dy, dx) if a < 0 { a += 2 * math.Pi } c.angle[idx] = a } } } } v.pulseCoordCache = c return c } func pulseDotIndex(row, col, dr, dc, width int) int { return ((row*width+col)*4+dr)*2 + dc } // renderPulse draws a pulsating ellipse using Braille dots that fills the // display width. The radius at each angle blends per-band frequency energy // with the overall level so the whole shape surges on every beat while still // deforming per frequency. A clean shockwave ring radiates outward on // transients. The interior is solid-filled with an anti-aliased edge and a // green→yellow→red radial color gradient. func (v *Visualizer) renderPulse(bands []float64) string { coords := v.pulseCoords() height := v.Rows width := PanelWidth bandCount := len(bands) maxR := coords.maxR var totalEnergy float64 for _, e := range bands { totalEnergy += e } avgEnergy := totalEnergy / float64(bandCount) // Shockwave: expanding ring that fades as it grows. shockPhase := math.Mod(float64(v.frame)*0.10, 1.0) shockR := maxR * (0.3 + 0.7*shockPhase) shockStrength := avgEnergy * avgEnergy * (1.0 - shockPhase*shockPhase) // Gentle breathing keeps the shape alive during silence. breath := math.Sin(float64(v.frame)*0.05) * 0.02 // Per-frame rotation offset — added uniformly to every cached angle. rotOffset := float64(v.frame) * (0.015 + avgEnergy*0.04) twoPi := 2 * math.Pi bandScale := float64(bandCount) / twoPi lines := make([]string, height) for row := range height { var sb, run strings.Builder tag := -1 for col := range width { var braille rune = '\u2800' var maxNorm float64 for dr := range 4 { for dc := range 2 { idx := pulseDotIndex(row, col, dr, dc, width) dist := coords.dist[idx] rotAngle := coords.angle[idx] + rotOffset rotAngle -= math.Floor(rotAngle/twoPi) * twoPi // Cosine-interpolated band mapping. bandPos := rotAngle * bandScale bandIdx := int(bandPos) % bandCount nextBand := (bandIdx + 1) % bandCount frac := bandPos - math.Floor(bandPos) t := (1 - math.Cos(frac*math.Pi)) / 2 energy := bands[bandIdx]*(1-t) + bands[nextBand]*t // Blend per-band with overall so the whole shape beats. blended := energy*0.6 + avgEnergy*0.4 punch := blended * blended r := maxR * (0.08 + breath + 0.92*punch) // --- Solid fill --- if r > 0.5 && dist <= r { norm := dist / r if norm > maxNorm { maxNorm = norm } braille |= brailleBit[dr][dc] } else if r > 0.5 && dist < r+1.5 { // Anti-aliased edge. edgeFade := 1.0 - (dist-r)/1.5 if scatterHash(bandIdx, row*4+dr, col*2+dc, v.frame) < edgeFade*0.7 { braille |= brailleBit[dr][dc] if maxNorm < 0.9 { maxNorm = 0.9 } } } // --- Shockwave ring --- if shockStrength > 0.05 { shockDist := math.Abs(dist - shockR) shockThick := 0.6 + shockStrength*1.5 if shockDist < shockThick { fade := 1.0 - shockDist/shockThick if fade > 0.4 { braille |= brailleBit[dr][dc] if maxNorm < 0.65 { maxNorm = 0.65 } } } } } } // Radial color gradient: green core → yellow → red edge. newTag := specTag(maxNorm) if newTag != tag { flushStyleRun(&sb, &run, tag) tag = newTag } run.WriteRune(braille) } flushStyleRun(&sb, &run, tag) lines[row] = sb.String() } return strings.Join(lines, "\n") }