mirror of
https://github.com/Luzifer/streamdeck.git
synced 2024-12-20 17:51:21 +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/Luzifer/streamdeck v0.0.0-20191122003228-a2f524a6b22c
|
||||||
github.com/fsnotify/fsnotify v1.4.7
|
github.com/fsnotify/fsnotify v1.4.7
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
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/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||||
github.com/pkg/errors v0.8.1
|
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 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
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/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 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.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
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