package main import ( "fmt" "image/color" "math" "os" "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/vector" "golang.org/x/image/font/basicfont" ) const ( screenW = 900 screenH = 480 fps = 60 vuRiseTime = 0.15 vuFallTime = 0.25 peakHoldSec = 2.5 mFaceW = 360 mFaceH = 260 mGap = 50 mStartX = (screenW - mFaceW*2 - mGap) / 2 mStartY = 80 scaleStartDeg = -35.0 scaleEndDeg = 35.0 needleLen = 140.0 pivotOffX = 180.0 pivotOffY = 225.0 dbMin = -20.0 dbMax = 3.0 ) func dbToDeg(db float64) float64 { // Clamp the dB value so the needle physically cannot drop out of the screen if db < dbMin { db = dbMin } if db > dbMax { db = dbMax } return scaleStartDeg + (db-dbMin)/(dbMax-dbMin)*(scaleEndDeg-scaleStartDeg) } func volToDB(v float64) float64 { if v <= 0 { return dbMin } // The incoming volume from PipeWire is 0 to 100, representing -60 dBFS to 0 dBFS dbFS := (v/100.0)*60.0 - 60.0 // Calibrate the VU meter: -12 dBFS equals 0 VU (creates headroom so it doesn't constantly peak) vu := dbFS + 12.0 return vu } type vuState struct { level float64 peak float64 peakAt time.Time } func newVU() *vuState { return &vuState{level: dbMin, peak: dbMin, peakAt: time.Now()} } func (v *vuState) tick(target float64, dt float64) { coeff := 1 - math.Exp(-dt/vuRiseTime) if target < v.level { coeff = 1 - math.Exp(-dt/vuFallTime) } v.level += (target - v.level) * coeff if v.level > v.peak { v.peak = v.level v.peakAt = time.Now() } else if time.Since(v.peakAt).Seconds() > peakHoldSec { v.peak -= peakDecayDBs * dt if v.peak < v.level { v.peak = v.level } } } const peakDecayDBs = 15.0 func (v *vuState) peaked() bool { // Trigger the peak LED if we exceed +1 VU return v.peak > 1.0 && time.Since(v.peakAt).Seconds() < peakHoldSec } type Game struct { ds *DataSource l, r *vuState prev time.Time } func NewGame() *Game { return &Game{ ds: NewDataSource(), l: newVU(), r: newVU(), prev: time.Now(), } } func (g *Game) Update() error { now := time.Now() dt := now.Sub(g.prev).Seconds() if dt > 0.1 { dt = 0.1 } g.prev = now lv, rv := g.ds.GetLevels() g.l.tick(volToDB(lv), dt) g.r.tick(volToDB(rv), dt) return nil } func (g *Game) Draw(s *ebiten.Image) { s.Fill(color.RGBA{25, 25, 30, 255}) drawBox(s, mStartX-16, mStartY-16, mFaceW*2+mGap+32, mFaceH+48, 10, color.RGBA{50, 48, 45, 255}) drawBox(s, mStartX-10, mStartY-10, mFaceW*2+mGap+20, mFaceH+36, 8, color.RGBA{35, 33, 30, 255}) g.drawFace(s, mStartX, mStartY, "LEFT", g.l) g.drawFace(s, mStartX+mFaceW+mGap, mStartY, "RIGHT", g.r) f := basicfont.Face7x13 text.Draw(s, "VU METER", f, screenW/2-30, 45, color.RGBA{180, 170, 150, 255}) } func (g *Game) drawFace(s *ebiten.Image, ox, oy int, ch string, m *vuState) { cream := color.RGBA{248, 243, 230, 255} drawBox(s, ox, oy, mFaceW, mFaceH, 8, cream) boxOutline(s, ox+5, oy+5, mFaceW-10, mFaceH-10, 6, color.RGBA{80, 70, 55, 255}, 1) px := float64(ox) + pivotOffX py := float64(oy) + pivotOffY g.drawArcAndLabels(s, px, py, ox, oy, m) needleDeg := dbToDeg(m.level) drawNeedle(s, px, py, needleDeg, needleLen, color.RGBA{25, 25, 25, 255}, 2.5) drawFilledCircle(s, int(px), int(py), 9, color.RGBA{70, 65, 55, 255}) drawFilledCircle(s, int(px), int(py), 6, color.RGBA{110, 105, 95, 255}) drawFilledCircle(s, int(px), int(py), 3, color.RGBA{150, 145, 135, 255}) f := basicfont.Face7x13 text.Draw(s, ch, f, ox+mFaceW/2-8, oy+mFaceH-12, color.RGBA{100, 90, 70, 255}) text.Draw(s, "VU", f, ox+mFaceW/2-6, oy+22, color.RGBA{140, 60, 50, 255}) } func drawPeakLED(s *ebiten.Image, ox, oy, faceW int, active bool) { ledX := ox + faceW - 28 ledY := oy + 30 drawFilledCircle(s, ledX, ledY, 9, color.RGBA{40, 35, 30, 255}) drawFilledCircle(s, ledX, ledY, 7, color.RGBA{30, 25, 20, 255}) if active { drawFilledCircle(s, ledX, ledY, 12, color.RGBA{255, 60, 20, 50}) drawFilledCircle(s, ledX, ledY, 10, color.RGBA{255, 50, 15, 70}) drawFilledCircle(s, ledX, ledY, 6, color.RGBA{255, 45, 15, 255}) drawFilledCircle(s, ledX-1, ledY-1, 3, color.RGBA{255, 140, 100, 200}) } else { drawFilledCircle(s, ledX, ledY, 6, color.RGBA{80, 20, 15, 255}) drawFilledCircle(s, ledX-1, ledY-1, 2, color.RGBA{100, 30, 20, 150}) } f := basicfont.Face7x13 text.Draw(s, "PEAK", f, ledX-14, ledY+18, color.RGBA{100, 90, 70, 255}) } func (g *Game) drawArcAndLabels(s *ebiten.Image, px, py float64, ox, oy int, m *vuState) { f := basicfont.Face7x13 arcCol := color.RGBA{160, 150, 130, 255} for a := scaleStartDeg; a <= scaleEndDeg; a += 0.3 { x, y := polar(px, py, a, needleLen+18) s.Set(int(x), int(y), arcCol) } type mark struct { db float64 label string major bool } marks := []mark{ {-20, "-20", true}, {-10, "-10", true}, {-7, "-7", false}, {-5, "-5", false}, {-3, "-3", false}, {-2, "-2", false}, {-1, "-1", false}, {0, "0", true}, {1, "+1", false}, {2, "+2", false}, {3, "+3", true}, } for _, m := range marks { deg := dbToDeg(m.db) inner := needleLen + 10 outer := needleLen + 22 if m.major { outer = needleLen + 28 } x1, y1 := polar(px, py, deg, inner) x2, y2 := polar(px, py, deg, outer) tickCol := color.RGBA{70, 60, 45, 255} if m.db == 0 { tickCol = color.RGBA{210, 45, 35, 255} } drawLine(s, x1, y1, x2, y2, tickCol, 1.5) // Increased width slightly for antialiasing if m.major { lx, ly := polar(px, py, deg, needleLen+40) lblCol := color.RGBA{70, 60, 45, 255} if m.db == 0 { lblCol = color.RGBA{210, 45, 35, 255} } text.Draw(s, m.label, f, int(lx)-len(m.label)*3, int(ly)+4, lblCol) } } for db := -20.0; db <= 3.0; db += 1.0 { isMajor := false for _, m := range marks { if m.db == db && m.major { isMajor = true } } if !isMajor { x1, y1 := polar(px, py, dbToDeg(db), needleLen+12) x2, y2 := polar(px, py, dbToDeg(db), needleLen+17) drawLine(s, x1, y1, x2, y2, color.RGBA{160, 150, 130, 255}, 1) } } for a := dbToDeg(0); a <= scaleEndDeg; a += 0.4 { for r := needleLen + 10; r < needleLen+15; r++ { x, y := polar(px, py, a, float64(r)) s.Set(int(x), int(y), color.RGBA{220, 80, 70, 60}) } } text.Draw(s, "dB", f, int(px)+50, int(py)-15, color.RGBA{120, 110, 90, 255}) drawPeakLED(s, ox, oy, mFaceW, m.peaked()) } func polar(px, py, deg, r float64) (float64, float64) { rad := (deg - 90) * math.Pi / 180 return px + r*math.Cos(rad), py + r*math.Sin(rad) } // Uses vector drawing with anti-aliasing (true flag) func drawNeedle(s *ebiten.Image, px, py, deg, length float64, col color.Color, w float64) { ex, ey := polar(px, py, deg, length) vector.StrokeLine(s, float32(px), float32(py), float32(ex), float32(ey), float32(w), col, true) } // Uses vector drawing with anti-aliasing func drawLine(s *ebiten.Image, x1, y1, x2, y2 float64, col color.Color, w float64) { vector.StrokeLine(s, float32(x1), float32(y1), float32(x2), float32(y2), float32(w), col, true) } // Replaced pixel-by-pixel rendering with anti-aliased vector rendering func drawFilledCircle(s *ebiten.Image, cx, cy, r int, col color.Color) { vector.DrawFilledCircle(s, float32(cx), float32(cy), float32(r), col, true) } func drawBox(s *ebiten.Image, x, y, w, h, r int, col color.Color) { ebitenutil.DrawRect(s, float64(x+r), float64(y), float64(w-2*r), float64(h), col) ebitenutil.DrawRect(s, float64(x), float64(y+r), float64(w), float64(h-2*r), col) for dy := 0; dy < r; dy++ { for dx := 0; dx < r; dx++ { if dx*dx+dy*dy <= r*r { s.Set(x+r-dx, y+r-dy, col) s.Set(x+w-r+dx, y+r-dy, col) s.Set(x+r-dx, y+h-r+dy, col) s.Set(x+w-r+dx, y+h-r+dy, col) } } } } func boxOutline(s *ebiten.Image, x, y, w, h, r int, col color.Color, t int) { for i := 0; i < t; i++ { ebitenutil.DrawRect(s, float64(x+r), float64(y+i), float64(w-2*r), 1, col) ebitenutil.DrawRect(s, float64(x+r), float64(y+h-1-i), float64(w-2*r), 1, col) ebitenutil.DrawRect(s, float64(x+i), float64(y+r), 1, float64(h-2*r), col) ebitenutil.DrawRect(s, float64(x+w-1-i), float64(y+r), 1, float64(h-2*r), col) } for dy := 0; dy < r; dy++ { for dx := 0; dx < r; dx++ { d := dx*dx + dy*dy if d >= (r-t)*(r-t) && d <= r*r { s.Set(x+r-dx, y+r-dy, col) s.Set(x+w-r+dx, y+r-dy, col) s.Set(x+r-dx, y+h-r+dy, col) s.Set(x+w-r+dx, y+h-r+dy, col) } } } } func maxInt(a, b int) int { if a > b { return a } return b } func (g *Game) Layout(ow, oh int) (int, int) { return screenW, screenH } func main() { g := NewGame() g.ds.Start() defer g.ds.Stop() ebiten.SetWindowSize(screenW, screenH) ebiten.SetWindowTitle("VU Meter") ebiten.SetTPS(fps) if err := ebiten.RunGame(g); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }