commit 57af0a439d3826f854dc0981b33f7f9b1c01488b Author: Joren Date: Thu Apr 2 21:47:00 2026 +0200 first commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/datasource.go b/datasource.go new file mode 100644 index 0000000..02629f6 --- /dev/null +++ b/datasource.go @@ -0,0 +1,31 @@ +package main + +/* +#cgo CFLAGS: -I/usr/include/pipewire-0.3 -I/usr/include/spa-0.2 +#cgo LDFLAGS: -lpipewire-0.3 -lm -lpthread +#include "pw_audio.h" +*/ +import "C" + +type DataSource struct { + left float64 + right float64 +} + +func NewDataSource() *DataSource { + return &DataSource{} +} + +func (ds *DataSource) Start() { + C.pw_audio_start() +} + +func (ds *DataSource) Stop() { + C.pw_audio_stop() +} + +func (ds *DataSource) GetLevels() (float64, float64) { + var l, r C.float + C.pw_audio_get_levels(&l, &r) + return float64(l), float64(r) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e2e04da --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module vumeter + +go 1.25.0 + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/hajimehoshi/ebiten/v2 v2.9.9 // indirect + github.com/jezek/xgb v1.1.1 // indirect + golang.org/x/image v0.38.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..47ae5ba --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M= +github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a31439 --- /dev/null +++ b/main.go @@ -0,0 +1,343 @@ +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) + } +} diff --git a/pw_audio.h b/pw_audio.h new file mode 100644 index 0000000..cb6889c --- /dev/null +++ b/pw_audio.h @@ -0,0 +1,189 @@ +#ifndef PW_AUDIO_H +#define PW_AUDIO_H + +#include +#include +#include +#include + +// Shared audio state +typedef struct { + float left; + float right; + float peak_left; + float peak_right; + int terminate; + pthread_mutex_t lock; + struct pw_main_loop *loop; + struct pw_stream *stream; +} pw_audio_t; + +static pw_audio_t g_audio; + +static float amplitude_to_db(float amplitude) { + if (amplitude <= 0.00001f) + return -60.0f; + float db = 20.0f * log10f(amplitude); + if (db < -60.0f) + db = -60.0f; + if (db > 0.0f) + db = 0.0f; + return db; +} + +// Normalize dB (-60..0) to 0..100 +static float db_to_level(float db) { + if (db <= -60.0f) + return 0.0f; + return ((db + 60.0f) / 60.0f) * 100.0f; +} + +static void on_process(void *userdata) { + pw_audio_t *data = userdata; + struct pw_buffer *b; + struct spa_buffer *buf; + float *samples; + uint32_t n_channels, n_samples; + + if (data->terminate) { + pw_main_loop_quit(data->loop); + return; + } + + if ((b = pw_stream_dequeue_buffer(data->stream)) == NULL) + return; + + buf = b->buffer; + if ((samples = buf->datas[0].data) == NULL) { + pw_stream_queue_buffer(data->stream, b); + return; + } + + n_channels = 2; // stereo + n_samples = buf->datas[0].chunk->size / sizeof(float); + + // Calculate RMS (Root Mean Square) instead of just peak values + // This gives a more accurate analog needle movement and prevents immediate + // pegging. + float sum_l = 0.0f, sum_r = 0.0f; + + for (uint32_t n = 0; n + 1 < n_samples; n += n_channels) { + float l = samples[n]; + float r = samples[n + 1]; + sum_l += l * l; + sum_r += r * r; + } + + float rms_l = sqrtf(sum_l / (n_samples / n_channels)); + float rms_r = sqrtf(sum_r / (n_samples / n_channels)); + + float db_l = amplitude_to_db(rms_l); + float db_r = amplitude_to_db(rms_r); + float level_l = db_to_level(db_l); + float level_r = db_to_level(db_r); + + pthread_mutex_lock(&data->lock); + data->left = level_l; + data->right = level_r; + + // Peak hold + if (level_l > data->peak_left) + data->peak_left = level_l; + if (level_r > data->peak_right) + data->peak_right = level_r; + pthread_mutex_unlock(&data->lock); + + pw_stream_queue_buffer(data->stream, b); +} + +static void on_stream_param_changed(void *_data, uint32_t id, + const struct spa_pod *param) { + pw_audio_t *data = _data; + struct spa_audio_info format; + + if (param == NULL || id != SPA_PARAM_Format) + return; + + if (spa_format_parse(param, &format.media_type, &format.media_subtype) < 0) + return; + + if (format.media_type != SPA_MEDIA_TYPE_audio || + format.media_subtype != SPA_MEDIA_SUBTYPE_raw) + return; +} + +static const struct pw_stream_events stream_events = { + PW_VERSION_STREAM_EVENTS, + .param_changed = on_stream_param_changed, + .process = on_process, +}; + +static void *pw_audio_thread(void *arg) { + pw_audio_t *data = &g_audio; + const struct spa_pod *params[1]; + uint8_t buffer[1024]; + struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); + + pw_init(0, NULL); + + data->loop = pw_main_loop_new(NULL); + if (data->loop == NULL) { + data->terminate = 1; + return NULL; + } + + struct pw_properties *props = pw_properties_new( + PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_CONFIG_NAME, "client-rt.conf", + PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE, "Music", NULL); + + pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true"); + + data->stream = + pw_stream_new_simple(pw_main_loop_get_loop(data->loop), "vumeter-capture", + props, &stream_events, data); + + params[0] = spa_format_audio_raw_build( + &b, SPA_PARAM_EnumFormat, + &SPA_AUDIO_INFO_RAW_INIT(.format = SPA_AUDIO_FORMAT_F32)); + + pw_stream_connect(data->stream, PW_DIRECTION_INPUT, PW_ID_ANY, + PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | + PW_STREAM_FLAG_RT_PROCESS, + params, 1); + + pw_main_loop_run(data->loop); + + pw_stream_destroy(data->stream); + pw_main_loop_destroy(data->loop); + pw_deinit(); + + return NULL; +} + +static int pw_audio_start() { + g_audio.left = 0; + g_audio.right = 0; + g_audio.peak_left = 0; + g_audio.peak_right = 0; + g_audio.terminate = 0; + pthread_mutex_init(&g_audio.lock, NULL); + + pthread_t thread; + return pthread_create(&thread, NULL, pw_audio_thread, NULL); +} + +static void pw_audio_get_levels(float *left, float *right) { + pthread_mutex_lock(&g_audio.lock); + *left = g_audio.left; + *right = g_audio.right; + pthread_mutex_unlock(&g_audio.lock); +} + +static void pw_audio_stop() { + g_audio.terminate = 1; + if (g_audio.loop) { + pw_main_loop_quit(g_audio.loop); + } +} + +#endif diff --git a/vumeter b/vumeter new file mode 100755 index 0000000..a7e4f70 Binary files /dev/null and b/vumeter differ