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

223 lines
5.7 KiB
Go

package ui
import (
"strings"
"time"
)
// mosaicDriver renders a static heatmap of small tiles. The grid never
// scrolls: each tile sits in a fixed (row, column) position and lights up or
// fades in place. Each cell is wired at startup to one frequency band and a
// personal ignition threshold, so loud passages light up many tiles at once
// while quiet passages light only the most-sensitive ones — producing a
// speckled, gradually-saturating pattern that tracks the music.
type mosaicDriver struct {
rows, tiles int
cells []mosaicCellState
rng uint64
}
type mosaicCellState struct {
bandIdx int // which spectrum band this cell listens to
threshold float64 // band level required to ignite this cell
value float64 // current displayed intensity, decays each tick
}
func newMosaicDriver() visModeDriver {
return &mosaicDriver{rng: 0xC1AB1A1015D5}
}
func (*mosaicDriver) AnalysisSpec(*Visualizer) VisAnalysisSpec {
return spectrumAnalysisSpec(DefaultSpectrumBands)
}
const (
mosaicCellW = 2 // characters per tile
mosaicCellGap = 1 // characters between tiles
mosaicDecay = 0.88
)
// mosaicLevel is one of the discrete brightness tiers a tile can show. Empty
// cells render as plain spaces so unlit positions disappear into the
// background, leaving only the active tiles visible.
type mosaicLevel struct {
glyph rune
tier int // 0 = green, 1 = yellow, 2 = red, -1 = no color
}
var mosaicLevels = []mosaicLevel{
{' ', -1},
{'░', 0},
{'▒', 0},
{'▓', 0},
{'█', 0},
{'█', 1}, // hot
{'█', 2}, // overdrive
}
func mosaicLevelFor(intensity float64) mosaicLevel {
switch {
case intensity >= 0.85:
return mosaicLevels[6]
case intensity >= 0.65:
return mosaicLevels[5]
case intensity >= 0.45:
return mosaicLevels[4]
case intensity >= 0.28:
return mosaicLevels[3]
case intensity >= 0.15:
return mosaicLevels[2]
case intensity >= 0.05:
return mosaicLevels[1]
default:
return mosaicLevels[0]
}
}
// mosaicTileCount returns how many tiles fit horizontally in panelWidth.
func mosaicTileCount(panelWidth int) int {
step := mosaicCellW + mosaicCellGap
if panelWidth < mosaicCellW {
return 0
}
// The last tile needs no trailing gap.
return (panelWidth + mosaicCellGap) / step
}
func (d *mosaicDriver) ensureGrid(rows, tiles, bandCount int) {
if rows == d.rows && tiles == d.tiles && len(d.cells) == rows*tiles {
return
}
d.rows = rows
d.tiles = tiles
d.cells = make([]mosaicCellState, rows*tiles)
if bandCount <= 0 {
bandCount = DefaultSpectrumBands
}
// Each cell picks a band biased toward its row (top → treble, bottom →
// bass) plus a small jitter so neighbors don't share the same band, and a
// per-cell threshold drawn from [0.04, 0.78] so the lit-cell density rises
// naturally with loudness — that's what produces the scattered look.
d.rng = 0xC1AB1A1015D5
for r := 0; r < rows; r++ {
baseBand := bandCount / 2
if rows > 1 {
baseBand = (rows - 1 - r) * (bandCount - 1) / (rows - 1)
}
for c := 0; c < tiles; c++ {
d.rng = d.rng*6364136223846793005 + 1442695040888963407
jitter := int((d.rng>>33)%5) - 2 // -2..+2
band := baseBand + jitter
if band < 0 {
band = 0
}
if band >= bandCount {
band = bandCount - 1
}
d.rng = d.rng*6364136223846793005 + 1442695040888963407
th := 0.04 + float64((d.rng>>33)%1000)/1000.0*0.74
d.cells[r*tiles+c] = mosaicCellState{
bandIdx: band,
threshold: th,
value: 0,
}
}
}
}
func (d *mosaicDriver) Render(v *Visualizer) string {
rows := v.Rows
tiles := mosaicTileCount(PanelWidth)
if rows <= 0 || tiles <= 0 {
return strings.Repeat("\n", max(0, rows-1))
}
d.ensureGrid(rows, tiles, len(v.SmoothedBands()))
lines := make([]string, rows)
for row := 0; row < rows; row++ {
var sb, run strings.Builder
tag := -1
for t := 0; t < tiles; t++ {
cell := d.cells[row*tiles+t]
level := mosaicLevelFor(cell.value)
if level.tier != tag {
flushStyleRun(&sb, &run, tag)
tag = level.tier
}
for c := 0; c < mosaicCellW; c++ {
run.WriteRune(level.glyph)
}
if t < tiles-1 {
if tag != -1 {
flushStyleRun(&sb, &run, tag)
tag = -1
}
for g := 0; g < mosaicCellGap; g++ {
run.WriteRune(' ')
}
}
}
flushStyleRun(&sb, &run, tag)
lines[row] = sb.String()
}
return strings.Join(lines, "\n")
}
func (d *mosaicDriver) Tick(v *Visualizer, ctx VisTickContext) {
defaultDriverTick(v, ctx, d.AnalysisSpec(v))
if ctx.OverlayActive {
return
}
rows := v.Rows
tiles := mosaicTileCount(PanelWidth)
if rows <= 0 || tiles <= 0 {
return
}
bands := v.SmoothedBands()
d.ensureGrid(rows, tiles, len(bands))
if len(bands) == 0 {
// Still decay so cells don't stick lit during silence.
for i := range d.cells {
d.cells[i].value *= mosaicDecay
}
return
}
// For each cell: if its assigned band exceeds the cell's threshold, ignite
// (set value to the band level — clamped to 1.05 so spikes can briefly
// promote into the yellow/red tiers). Otherwise decay in place.
for i := range d.cells {
c := &d.cells[i]
level := bands[c.bandIdx]
if level > c.threshold {
ignited := level
if ignited > 1.05 {
ignited = 1.05
}
if ignited > c.value {
c.value = ignited
}
}
c.value *= mosaicDecay
if c.value < 0.001 {
c.value = 0
}
}
}
func (*mosaicDriver) TickInterval(_ *Visualizer, ctx VisTickContext) time.Duration {
return defaultDriverTickInterval(ctx)
}
func (d *mosaicDriver) OnEnter(*Visualizer) {
// Force the grid to be regenerated on next Render/Tick so each visit
// reshuffles thresholds and band assignments — keeps the visualizer fresh.
d.cells = nil
d.rows = 0
d.tiles = 0
}
func (*mosaicDriver) OnLeave(*Visualizer) {}