mirror of
https://github.com/Luzifer/streamdeck.git
synced 2024-12-20 09:41:19 +00:00
Add pulseaudio volume display
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
862c9ec7c4
commit
661edb591a
4 changed files with 240 additions and 0 deletions
125
cmd/streamdeck/display_pulsevolume.go
Normal file
125
cmd/streamdeck/display_pulsevolume.go
Normal file
|
@ -0,0 +1,125 @@
|
|||
// +build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerDisplayElement("pulsevolume", &displayElementPulseVolume{})
|
||||
}
|
||||
|
||||
type displayElementPulseVolume struct{}
|
||||
|
||||
func (d displayElementPulseVolume) Display(ctx context.Context, idx int, attributes map[string]interface{}) error {
|
||||
if pulseClient == nil {
|
||||
return errors.New("PulseAudio client not initialized")
|
||||
}
|
||||
|
||||
sinkMatch, sinkOK := attributes["sink"].(string)
|
||||
sinkOK = sinkOK && sinkMatch != ""
|
||||
|
||||
sinkInputMatch, sinkInputOK := attributes["sink_input"].(string)
|
||||
sinkInputOK = sinkInputOK && sinkInputMatch != ""
|
||||
|
||||
var (
|
||||
err error
|
||||
volume float64
|
||||
)
|
||||
|
||||
switch {
|
||||
|
||||
case (sinkInputOK && sinkOK) || (!sinkInputOK && !sinkOK):
|
||||
return errors.New("Exactly one of 'sink' and 'sink_input' must be specified")
|
||||
|
||||
case sinkInputOK:
|
||||
volume, err = pulseClient.GetSinkInputVolume(sinkInputMatch)
|
||||
|
||||
case sinkOK:
|
||||
volume, err = pulseClient.GetSinkVolume(sinkMatch)
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to get volume")
|
||||
}
|
||||
|
||||
img := newTextOnImageRenderer()
|
||||
|
||||
// Initialize color
|
||||
var textColor color.Color = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||
if rgba, ok := attributes["color"].([]interface{}); ok {
|
||||
if len(rgba) != 4 {
|
||||
return errors.New("RGBA color definition needs 4 hex values")
|
||||
}
|
||||
|
||||
tmpCol := color.RGBA{}
|
||||
|
||||
for cidx, vp := range []*uint8{&tmpCol.R, &tmpCol.G, &tmpCol.B, &tmpCol.A} {
|
||||
switch rgba[cidx].(type) {
|
||||
case int:
|
||||
*vp = uint8(rgba[cidx].(int))
|
||||
case float64:
|
||||
*vp = uint8(rgba[cidx].(float64))
|
||||
}
|
||||
}
|
||||
|
||||
textColor = tmpCol
|
||||
}
|
||||
|
||||
// Initialize fontsize
|
||||
var fontsize float64 = 120
|
||||
if v, ok := attributes["font_size"].(float64); ok {
|
||||
fontsize = v
|
||||
}
|
||||
|
||||
var border = 10
|
||||
if v, ok := attributes["border"].(int); ok {
|
||||
border = v
|
||||
}
|
||||
|
||||
if err = img.DrawBigText(fmt.Sprintf("%.0f%%", volume*100), fontsize, border, textColor); err != nil {
|
||||
return errors.Wrap(err, "Unable to draw text")
|
||||
}
|
||||
|
||||
if err = ctx.Err(); err != nil {
|
||||
// Page context was cancelled, do not draw
|
||||
return err
|
||||
}
|
||||
|
||||
return errors.Wrap(sd.FillImage(idx, img.GetImage()), "Unable to set image")
|
||||
}
|
||||
|
||||
func (d displayElementPulseVolume) NeedsLoop(attributes map[string]interface{}) bool { return true }
|
||||
|
||||
func (d *displayElementPulseVolume) StartLoopDisplay(ctx context.Context, idx int, attributes map[string]interface{}) error {
|
||||
var interval = time.Second
|
||||
if v, ok := attributes["interval"].(int); ok {
|
||||
interval = time.Duration(v) * time.Second
|
||||
}
|
||||
|
||||
go func() {
|
||||
for tick := time.NewTicker(interval); ; <-tick.C {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := d.Display(ctx, idx, attributes); err != nil {
|
||||
log.WithError(err).Error("Unable to refresh element")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *displayElementPulseVolume) StopLoopDisplay() error {
|
||||
return nil
|
||||
}
|
|
@ -10,6 +10,7 @@ require (
|
|||
github.com/Luzifer/streamdeck v0.0.0-20191122003228-a2f524a6b22c
|
||||
github.com/fsnotify/fsnotify v1.4.7
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||
github.com/jfreymuth/pulse v0.0.0-20200804114219-7d61c4938214
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/pkg/errors v0.8.1
|
||||
|
|
|
@ -11,6 +11,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
|||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||
github.com/jfreymuth/pulse v0.0.0-20200804114219-7d61c4938214 h1:2xVJKIumEUWeV3vczQwn61SHjNZ94Bwk+4CTjmcePxk=
|
||||
github.com/jfreymuth/pulse v0.0.0-20200804114219-7d61c4938214/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||
|
|
112
cmd/streamdeck/pulseaudio.go
Normal file
112
cmd/streamdeck/pulseaudio.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
// +build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"regexp"
|
||||
|
||||
"github.com/jfreymuth/pulse"
|
||||
"github.com/jfreymuth/pulse/proto"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var pulseClient *pulseAudioClient
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
if pulseClient, err = newPulseAudioClient(); err != nil {
|
||||
log.WithError(err).Error("Unable to connect to PulseAudio, functionality will not work")
|
||||
}
|
||||
}
|
||||
|
||||
type pulseAudioClient struct {
|
||||
client *pulse.Client
|
||||
}
|
||||
|
||||
func newPulseAudioClient() (*pulseAudioClient, error) {
|
||||
c, err := pulse.NewClient()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Unable to create pulse client")
|
||||
}
|
||||
|
||||
return &pulseAudioClient{client: c}, nil
|
||||
}
|
||||
|
||||
func (p pulseAudioClient) Close() { p.client.Close() }
|
||||
|
||||
func (p pulseAudioClient) GetSinkInputVolume(match string) (float64, error) {
|
||||
m, err := regexp.Compile(match)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to compile given match RegEx")
|
||||
}
|
||||
|
||||
var resp proto.GetSinkInputInfoListReply
|
||||
if err := p.client.RawRequest(&proto.GetSinkInputInfoList{}, &resp); err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to list sink inputs")
|
||||
}
|
||||
|
||||
for _, info := range resp {
|
||||
if !m.MatchString(info.MediaName) && !m.Match(info.Properties["application.name"]) {
|
||||
continue
|
||||
}
|
||||
|
||||
sinkBase, err := p.getSinkBaseVolumeByIndex(info.SinkIndex)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to get sink base volume")
|
||||
}
|
||||
|
||||
return p.unifyChannelVolumes(info.ChannelVolumes) / sinkBase, nil
|
||||
}
|
||||
|
||||
return 0, errors.New("No such sink")
|
||||
}
|
||||
|
||||
func (p pulseAudioClient) GetSinkVolume(match string) (float64, error) {
|
||||
m, err := regexp.Compile(match)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to compile given match RegEx")
|
||||
}
|
||||
|
||||
var resp proto.GetSinkInfoListReply
|
||||
if err := p.client.RawRequest(&proto.GetSinkInfoList{}, &resp); err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to list sinks")
|
||||
}
|
||||
|
||||
for _, info := range resp {
|
||||
if !m.MatchString(info.SinkName) && !m.MatchString(info.Device) {
|
||||
continue
|
||||
}
|
||||
|
||||
return p.unifyChannelVolumes(info.ChannelVolumes) / float64(info.BaseVolume), nil
|
||||
}
|
||||
|
||||
return 0, errors.New("No such sink")
|
||||
}
|
||||
|
||||
func (p pulseAudioClient) getSinkBaseVolumeByIndex(idx uint32) (float64, error) {
|
||||
var resp proto.GetSinkInfoReply
|
||||
if err := p.client.RawRequest(&proto.GetSinkInfo{SinkIndex: idx}, &resp); err != nil {
|
||||
return 0, errors.Wrap(err, "Unable to get sink")
|
||||
}
|
||||
|
||||
return float64(resp.BaseVolume), nil
|
||||
}
|
||||
|
||||
func (p pulseAudioClient) unifyChannelVolumes(v proto.ChannelVolumes) float64 {
|
||||
if len(v) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
if len(v) == 1 {
|
||||
return float64(v[0])
|
||||
}
|
||||
|
||||
var vMin = float64(v[0])
|
||||
for i := 1; i < len(v); i++ {
|
||||
vMin = math.Min(vMin, float64(v[i]))
|
||||
}
|
||||
|
||||
return vMin
|
||||
}
|
Loading…
Reference in a new issue