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) {}