first commit
This commit is contained in:
31
datasource.go
Normal file
31
datasource.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
16
go.sum
Normal file
16
go.sum
Normal file
@@ -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=
|
||||||
343
main.go
Normal file
343
main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
189
pw_audio.h
Normal file
189
pw_audio.h
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
#ifndef PW_AUDIO_H
|
||||||
|
#define PW_AUDIO_H
|
||||||
|
|
||||||
|
#include <math.h>
|
||||||
|
#include <pipewire/pipewire.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
#include <spa/param/audio/format-utils.h>
|
||||||
|
|
||||||
|
// 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
|
||||||
Reference in New Issue
Block a user