Skip to content

Commit

Permalink
feat: synchronisation between ALSA and Spotify volume (#40)
Browse files Browse the repository at this point in the history
* poc for implementing synchronization between alsa mixer and spotify volume

* feat: don't touch mixer volume if externalVolume is enabled

* extracted RingBuffer to struct

* fixed unexpected issues with external volume

* let mixer and externalVolume behave as specified

* un-capitalize log messages
  • Loading branch information
tooxo authored Jul 2, 2024
1 parent ba696c7 commit b6bd7d7
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 47 deletions.
40 changes: 33 additions & 7 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
log "github.com/sirupsen/logrus"
"go-librespot/apresolve"
"go-librespot/output"
"go-librespot/player"
devicespb "go-librespot/proto/spotify/connectstate/devices"
"go-librespot/session"
Expand Down Expand Up @@ -69,10 +70,11 @@ func NewApp(cfg *Config) (app *App, err error) {

func (app *App) newAppPlayer(creds any) (_ *AppPlayer, err error) {
appPlayer := &AppPlayer{
app: app,
stop: make(chan struct{}, 1),
logout: app.logoutCh,
countryCode: new(string),
app: app,
stop: make(chan struct{}, 1),
logout: app.logoutCh,
countryCode: new(string),
externalVolumeUpdate: output.NewRingBuffer[float32](1),
}

// start a dummy timer for prefetching next media
Expand All @@ -93,12 +95,31 @@ func (app *App) newAppPlayer(creds any) (_ *AppPlayer, err error) {
if appPlayer.player, err = player.NewPlayer(
appPlayer.sess.Spclient(), appPlayer.sess.AudioKey(),
!app.cfg.NormalisationDisabled, *app.cfg.NormalisationPregain,
appPlayer.countryCode, *app.cfg.AudioDevice, *app.cfg.MixerDevice,
*app.cfg.VolumeSteps, app.cfg.ExternalVolume,
appPlayer.countryCode, *app.cfg.AudioDevice, *app.cfg.MixerDevice, *app.cfg.MixerControlName,
*app.cfg.VolumeSteps, app.cfg.ExternalVolume, appPlayer.externalVolumeUpdate,
); err != nil {
return nil, fmt.Errorf("failed initializing player: %w", err)
}

// only update the "spotify volume", when external volume is enabled or a mixer is defined
// try to keep synchronized with the device volume
if app.cfg.ExternalVolume || len(*app.cfg.MixerDevice) > 0 {
// listen on external volume changes (for example the alsa driver)
go func() {
for {
v, ok := appPlayer.externalVolumeUpdate.Get()
if !ok {
break
}

appPlayer.updateVolume(uint32(v * player.MaxStateVolume))

// prevent "too many requests"
time.Sleep(2 * time.Second)
}
}()
}

return appPlayer, nil
}

Expand Down Expand Up @@ -313,6 +334,7 @@ type Config struct {
ClientToken *string `yaml:"client_token"`
AudioDevice *string `yaml:"audio_device"`
MixerDevice *string `yaml:"mixer_device"`
MixerControlName *string `yaml:"mixer_control_name"`
Bitrate *int `yaml:"bitrate"`
VolumeSteps *uint32 `yaml:"volume_steps"`
InitialVolume *uint32 `yaml:"initial_volume"`
Expand Down Expand Up @@ -371,7 +393,11 @@ func loadConfig(cfg *Config) error {
}
if cfg.MixerDevice == nil {
cfg.MixerDevice = new(string)
*cfg.MixerDevice = "default"
*cfg.MixerDevice = ""
}
if cfg.MixerControlName == nil {
cfg.MixerControlName = new(string)
*cfg.MixerControlName = "Master"
}
if cfg.Bitrate == nil {
cfg.Bitrate = new(int)
Expand Down
16 changes: 10 additions & 6 deletions cmd/daemon/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
librespot "go-librespot"
"go-librespot/ap"
"go-librespot/dealer"
"go-librespot/output"
"go-librespot/player"
connectpb "go-librespot/proto/spotify/connectstate"
"go-librespot/session"
Expand All @@ -25,8 +26,9 @@ type AppPlayer struct {
stop chan struct{}
logout chan *AppPlayer

player *player.Player
initialVolumeOnce sync.Once
player *player.Player
initialVolumeOnce sync.Once
externalVolumeUpdate output.RingBuffer[float32]

spotConnId string

Expand Down Expand Up @@ -72,10 +74,12 @@ func (p *AppPlayer) handleDealerMessage(msg dealer.Message) error {
return fmt.Errorf("failed initial state put: %w", err)
}

// update initial volume
p.initialVolumeOnce.Do(func() {
p.updateVolume(*p.app.cfg.InitialVolume * player.MaxStateVolume / *p.app.cfg.VolumeSteps)
})
if !p.app.cfg.ExternalVolume && len(*p.app.cfg.MixerDevice) != 0 {
// update initial volume
p.initialVolumeOnce.Do(func() {
p.updateVolume(*p.app.cfg.InitialVolume * player.MaxStateVolume / *p.app.cfg.VolumeSteps)
})
}
} else if strings.HasPrefix(msg.Uri, "hm://connect-state/v1/connect/volume") {
var setVolCmd connectpb.SetVolumeCommand
if err := proto.Unmarshal(msg.Payload, &setVolCmd); err != nil {
Expand Down
9 changes: 7 additions & 2 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,13 @@
},
"mixer_device": {
"type": "string",
"description": "Which audio mixer should be used for volume control, leave empty for default",
"default": "default"
"description": "Which audio mixer should be used for volume control, leave empty to disable mixer control",
"default": ""
},
"mixer_control_name": {
"type": "string",
"description": "Which control should be used, leave empty for default. Only useful in combination with 'mixer_device'",
"default": "Master"
},
"server": {
"type": "object",
Expand Down
124 changes: 105 additions & 19 deletions output/driver_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package output
// #cgo pkg-config: alsa
//
// #include <alsa/asoundlib.h>
// extern int alsaMixerCallback(snd_mixer_elem_t*, unsigned int);
//
import "C"
import (
"errors"
Expand All @@ -26,14 +28,18 @@ type output struct {
channels int
sampleRate int
device string
mixer string
reader librespot.Float32Reader

cond *sync.Cond

pcmHandle *C.snd_pcm_t
canPause bool

externalVolume bool

mixer string
control string

mixerEnabled bool
mixerHandle *C.snd_mixer_t
mixerElemHandle *C.snd_mixer_elem_t
Expand All @@ -45,19 +51,23 @@ type output struct {
closed bool
released bool

err chan error
externalVolumeUpdate RingBuffer[float32]
err chan error
}

func newOutput(reader librespot.Float32Reader, sampleRate int, channels int, device string, mixer string, initialVolume float32) (*output, error) {
func newOutput(reader librespot.Float32Reader, sampleRate int, channels int, device string, mixer string, control string, initialVolume float32, externalVolume bool, externalVolumeUpdate RingBuffer[float32]) (*output, error) {
out := &output{
reader: reader,
channels: channels,
sampleRate: sampleRate,
device: device,
mixer: mixer,
volume: initialVolume,
err: make(chan error, 1),
cond: sync.NewCond(&sync.Mutex{}),
reader: reader,
channels: channels,
sampleRate: sampleRate,
device: device,
mixer: mixer,
control: control,
volume: initialVolume,
err: make(chan error, 1),
cond: sync.NewCond(&sync.Mutex{}),
externalVolume: externalVolume,
externalVolumeUpdate: externalVolumeUpdate,
}

if err := out.openAndSetup(); err != nil {
Expand Down Expand Up @@ -173,7 +183,7 @@ func (out *output) openAndSetupMixer() error {
defer C.free(unsafe.Pointer(sid))

C.snd_mixer_selem_id_set_index(sid, 0)
C.snd_mixer_selem_id_set_name(sid, C.CString("Master"))
C.snd_mixer_selem_id_set_name(sid, C.CString(out.control))

if out.mixerElemHandle = C.snd_mixer_find_selem(out.mixerHandle, sid); uintptr(unsafe.Pointer(out.mixerElemHandle)) == 0 {
return fmt.Errorf("mixer simple element not found")
Expand All @@ -183,16 +193,88 @@ func (out *output) openAndSetupMixer() error {
return out.alsaError("snd_mixer_selem_get_playback_volume_range", err)
}

// set initial volume and verify it actually works
mixerVolume := out.volume*(float32(out.mixerMaxVolume-out.mixerMinVolume)) + float32(out.mixerMinVolume)
if err := C.snd_mixer_selem_set_playback_volume_all(out.mixerElemHandle, C.long(mixerVolume)); err != 0 {
return out.alsaError("snd_mixer_selem_set_playback_volume_all", err)
}
// get current volume from the mixer, and set the spotify volume accordingly
var volume C.long
C.snd_mixer_selem_get_playback_volume(out.mixerElemHandle, C.SND_MIXER_SCHN_MONO, &volume)
out.volume = float32(volume-out.mixerMinVolume) / float32(out.mixerMaxVolume-out.mixerMinVolume)

out.externalVolumeUpdate.Put(out.volume)

// set callback and initialize private
var cb C.snd_mixer_elem_callback_t = (C.snd_mixer_elem_callback_t)(C.alsaMixerCallback)
C.snd_mixer_elem_set_callback(out.mixerElemHandle, cb)
C.snd_mixer_elem_set_callback_private(out.mixerElemHandle, unsafe.Pointer(&out.volume))

go out.waitForMixerEvents()

out.mixerEnabled = true
return nil
}

func (out *output) waitForMixerEvents() {
for !out.closed {
var res = C.snd_mixer_wait(out.mixerHandle, -1)
if out.closed {
// if we reach here, the playing context has probably changed
break
}
if res >= 0 {
res = C.snd_mixer_handle_events(out.mixerHandle)
if res <= 0 {
errStrPtr := C.snd_strerror(res)
log.Warnf("error while handling alsa mixer events. (%s)\n", string(C.GoString(errStrPtr)))

// no need to free the errStrPtr, because it doesn't point into heap
continue
}

var priv = float32(*(*C.float)(C.snd_mixer_elem_get_callback_private(out.mixerElemHandle)))
if priv < 0 {
// volume update came from spotify, so no need to tell spotify about it
// reset the private, but discard the event
C.snd_mixer_elem_set_callback_private(out.mixerElemHandle, unsafe.Pointer(&out.volume))

continue
}
if priv == out.volume {
log.Debugf("skipping alsa mixer event, volume already updated: %.2f\n", priv)
continue
}

out.externalVolumeUpdate.Put(priv)
} else {
errStrPtr := C.snd_strerror(res)
log.Warnf("error while waiting for alsa mixer events. (%s)\n", string(C.GoString(errStrPtr)))
}
}
}

/*
The mixer callback private is used to pass the detected volume from c back to go code.
A private value between zero and one (inclusive) means, that the volume changed to that percentage of the maximum volume.
A private value less than zero means, that the volume update was initiated by spotify instead of the alsa mixer.
*/
//export alsaMixerCallback
func alsaMixerCallback(elem *C.snd_mixer_elem_t, _ C.uint) C.int {
if float32(*(*C.float)(C.snd_mixer_elem_get_callback_private(elem))) < 0 {
// the volume update came from spotify, so there is no need to tell spotify about it
return 0
}

var val C.long
var minVol C.long
var maxVol C.long
C.snd_mixer_selem_get_playback_volume(elem, C.SND_MIXER_SCHN_MONO, &val)
C.snd_mixer_selem_get_playback_volume_range(elem, &minVol, &maxVol)

var normalizedVolume = C.float(float32(val-minVol) / float32(maxVol-minVol))
C.snd_mixer_elem_set_callback_private(elem, unsafe.Pointer(&normalizedVolume))

return 0
}

func (out *output) loop() error {
floats := make([]float32, out.channels*16*1024)

Expand All @@ -218,7 +300,7 @@ func (out *output) loop() error {
return fmt.Errorf("invalid read amount: %d", n)
}

if !out.mixerEnabled {
if !out.mixerEnabled && !out.externalVolume {
for i := 0; i < n; i++ {
floats[i] *= out.volume
}
Expand Down Expand Up @@ -354,8 +436,12 @@ func (out *output) SetVolume(vol float32) {

out.volume = vol

if out.mixerEnabled {
if out.mixerEnabled && !out.externalVolume {
placeholder := C.float(-1)
C.snd_mixer_elem_set_callback_private(out.mixerElemHandle, unsafe.Pointer(&placeholder))

mixerVolume := vol*(float32(out.mixerMaxVolume-out.mixerMinVolume)) + float32(out.mixerMinVolume)
log.Debugf("updating alsa mixer volume to %.02f\n", mixerVolume)
if err := C.snd_mixer_selem_set_playback_volume_all(out.mixerElemHandle, C.long(mixerVolume)); err != 0 {
log.WithError(out.alsaError("snd_mixer_selem_set_playback_volume_all", err)).Warnf("failed setting output device mixer volume")
}
Expand Down
34 changes: 33 additions & 1 deletion output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,52 @@ type NewOutputOptions struct {
//
// This feature is support only for the unix driver.
Mixer string
// Control specifies the mixer control name
//
// This only works in combination with Mixer
Control string

// InitialVolume specifies the initial output volume.
InitialVolume float32

// ExternalVolume specifies, if the volume is controlled outside of the app.
ExternalVolume bool

ExternalVolumeUpdate RingBuffer[float32]
}

func NewOutput(options *NewOutputOptions) (*Output, error) {
out, err := newOutput(options.Reader, options.SampleRate, options.ChannelCount, options.Device, options.Mixer, options.InitialVolume)
out, err := newOutput(options.Reader, options.SampleRate, options.ChannelCount, options.Device, options.Mixer, options.Control, options.InitialVolume, options.ExternalVolume, options.ExternalVolumeUpdate)
if err != nil {
return nil, err
}

return &Output{out}, nil
}

type RingBuffer[T any] struct {
inner chan T
}

func NewRingBuffer[T any](capacity uint64) RingBuffer[T] {
return RingBuffer[T]{
inner: make(chan T, capacity),
}
}

func (b RingBuffer[T]) Put(val T) {
if len(b.inner) == cap(b.inner) {
_ = <-b.inner
}
b.inner <- val
}

func (b RingBuffer[T]) Get() (T, bool) {
v, ok := <-b.inner

return v, ok
}

// Pause pauses the output.
func (c *Output) Pause() error {
return c.output.Pause()
Expand Down
Loading

0 comments on commit b6bd7d7

Please sign in to comment.