Skip to content

Commit

Permalink
feat: add PulseAudio backend
Browse files Browse the repository at this point in the history
This backend may integrate a bit better in desktop environments for
example. It also supports instantaneous volume updates, instead of
waiting around 500ms due to the ALSA buffer size.

Tested on a PipeWire system, which implements the same protocol.
  • Loading branch information
aykevl authored and devgianlu committed Nov 7, 2024
1 parent b555245 commit cf97e9e
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 10 deletions.
4 changes: 3 additions & 1 deletion cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ 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.MixerControlName,
appPlayer.countryCode, app.cfg.AudioBackend, app.cfg.AudioDevice, app.cfg.MixerDevice, app.cfg.MixerControlName,
app.cfg.VolumeSteps, app.cfg.ExternalVolume, appPlayer.volumeUpdate,
); err != nil {
return nil, fmt.Errorf("failed initializing player: %w", err)
Expand Down Expand Up @@ -337,6 +337,7 @@ type Config struct {
DeviceName string `koanf:"device_name"`
DeviceType string `koanf:"device_type"`
ClientToken string `koanf:"client_token"`
AudioBackend string `koanf:"audio_backend"`
AudioDevice string `koanf:"audio_device"`
MixerDevice string `koanf:"mixer_device"`
MixerControlName string `koanf:"mixer_control_name"`
Expand Down Expand Up @@ -411,6 +412,7 @@ func loadConfig(cfg *Config) error {
_ = k.Load(confmap.Provider(map[string]interface{}{
"log_level": log.InfoLevel,
"device_type": "computer",
"audio_backend": "alsa",
"audio_device": "default",
"mixer_control_name": "Master",
"bitrate": 160,
Expand Down
9 changes: 9 additions & 0 deletions config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@
"type": "string",
"description": "The Spotify client token"
},
"audio_backend": {
"type": "string",
"description": "Which audio backend (API) should be used for playback, leave empty for default",
"enum": [
"alsa",
"pulseaudio"
],
"default": "alsa"
},
"audio_device": {
"type": "string",
"description": "Which audio device should be used for playback, leave empty for default",
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/devgianlu/shannon v0.0.0-20230613115856-82ec90b7fa7e
github.com/gofrs/flock v0.12.1
github.com/grandcat/zeroconf v1.0.0
github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53
github.com/knadh/koanf/parsers/yaml v0.1.0
github.com/knadh/koanf/providers/confmap v0.1.0
github.com/knadh/koanf/providers/file v1.1.0
Expand All @@ -20,7 +21,6 @@ require (
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
golang.org/x/net v0.26.0
golang.org/x/oauth2 v0.21.0
golang.org/x/sys v0.22.0
google.golang.org/protobuf v1.30.0
nhooyr.io/websocket v1.8.7
)
Expand All @@ -38,6 +38,7 @@ require (
github.com/rogpeppe/go-internal v1.13.1 // indirect
golang.org/x/mod v0.18.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/tools v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53 h1:bwsfDCV1qoqA3ooZfP6zvNr5RCjYRxItKODBiJzOQOc=
github.com/jfreymuth/pulse v0.1.2-0.20241102120944-4ffb35054b53/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
Expand Down
186 changes: 186 additions & 0 deletions output/driver-pulseaudio.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package output

import (
"fmt"
"io"
"sync"

librespot "github.com/devgianlu/go-librespot"
"github.com/jfreymuth/pulse"
"github.com/jfreymuth/pulse/proto"
log "github.com/sirupsen/logrus"
)

type pulseAudioOutput struct {
sampleRate int
reader librespot.Float32Reader
client *pulse.Client
stream *pulse.PlaybackStream
volume proto.Volume
volumeLock sync.Mutex
externalVolumeUpdate chan float32
err chan error
}

func newPulseAudioOutput(reader librespot.Float32Reader, sampleRate int, channels int, externalVolumeUpdate chan float32) (*pulseAudioOutput, error) {
// Initialize the PulseAudio client.
// The device name is shown by PulseAudio volume controls (usually built
// into a desktop environment), so we might want to use device_name here.
// We could also maybe change the application icon name by device_type.
client, err := pulse.NewClient(pulse.ClientApplicationName("go-librespot"), pulse.ClientApplicationIconName("speaker"))
if err != nil {
return nil, err
}
out := &pulseAudioOutput{
sampleRate: sampleRate,
reader: reader,
client: client,
externalVolumeUpdate: externalVolumeUpdate,
err: make(chan error, 2),
}

// Create a new playback.
var channelOpt pulse.PlaybackOption
if channels == 1 {
channelOpt = pulse.PlaybackMono
} else if channels == 2 {
channelOpt = pulse.PlaybackStereo
} else {
return nil, fmt.Errorf("cannot play %d channels, pulse only supports mono and stereo", channels)
}
volumeUpdates := make(chan proto.ChannelVolumes, 1)
out.stream, err = out.client.NewPlayback(pulse.Float32Reader(out.float32Reader), pulse.PlaybackSampleRate(out.sampleRate), channelOpt, pulse.PlaybackVolumeChanges(volumeUpdates))
if err != nil {
return nil, err
}

// Read the initial volume from PulseAudio.
// PulseAudio strongly recommends against setting a default volume at
// startup (especially if it's 100%), so instead we just follow the
// PulseAudio provided volume.
cvol, _ := out.stream.Volume()
out.volume = cvol.Avg()
sendVolumeUpdate(externalVolumeUpdate, float32(out.volume.Norm()))

// Listen for volume changes (through the volume mixer application, usually
// built into the desktop environment), and send them back to Spotify.
go func() {
for cvol := range volumeUpdates {
volume := cvol.Avg()

out.volumeLock.Lock()
if volume != out.volume {
sendVolumeUpdate(externalVolumeUpdate, float32(volume.Norm()))
out.volume = volume
}
out.volumeLock.Unlock()
}
}()

return out, nil
}

func (out *pulseAudioOutput) float32Reader(buf []float32) (int, error) {
n, err := out.reader.Read(buf)
if err != nil {
if err == io.EOF {
// Might happen, so translate this error message.
return n, pulse.EndOfData
}

// Encountered another error. This will result in a stopped player, so
// send the error back to the player using a non-blocking send.
select {
case out.err <- err:
default:
}
return n, err
}
return n, err
}

func (out *pulseAudioOutput) Pause() error {
if out.stream.Running() {
// Stop() will stop new samples from being requested, but will continue
// to play whatever is in the buffer.
out.stream.Stop()

// To really stop playback *now*, we have to also flush everything
// that's in the buffer.
err := out.client.RawRequest(&proto.FlushPlaybackStream{
StreamIndex: out.stream.StreamIndex(),
}, nil)
if err != nil {
return fmt.Errorf("Pause: could not flush playback: %e", err)
}
} else {
// Nothing to do: we're already paused.
}

return nil
}

func (out *pulseAudioOutput) Resume() error {
// Start the stream. This will start reading samples from out.reader and
// push it to PulseAudio. It will do nothing if the playback is already
// started.
out.stream.Start()
return nil
}

func (out *pulseAudioOutput) Drop() error {
if out.stream.Running() {
// Drop all samples while running. This happens when seeking.
// So we stop playback, flush the buffer, and restart it again to clear
// what's in the buffer. Presumably, all new samples from this point on
// are the new samples (isn't there a race condition here with
// SwitchingAudioSource?).
out.stream.Stop()
err := out.client.RawRequest(&proto.FlushPlaybackStream{
StreamIndex: out.stream.StreamIndex(),
}, nil)
if err != nil {
return fmt.Errorf("Drop: could not flush playback: %e", err)
}
out.stream.Start()
} else {
// This sometimes happens. But we don't need to do anything: we already
// flushed the buffer in Pause().
}
return nil
}

func (out *pulseAudioOutput) DelayMs() (int64, error) {
samples := out.stream.BufferSize()
delay := int64(samples) * 1000 / int64(out.sampleRate)
return delay, nil
}

func (out *pulseAudioOutput) SetVolume(vol float32) {
volume := proto.NormVolume(float64(vol))

out.volumeLock.Lock()
if volume == out.volume {
out.volumeLock.Unlock()
return
}
out.volume = volume
sendVolumeUpdate(out.externalVolumeUpdate, vol)
out.volumeLock.Unlock()

cvol := proto.ChannelVolumes{volume}
err := out.stream.SetVolume(cvol)
if err != nil {
log.Warnln("failed to set volume:", err)
}
}

func (out *pulseAudioOutput) Error() <-chan error {
return out.err
}

func (out *pulseAudioOutput) Close() error {
out.stream.Close()
out.client.Close()
return nil
}
35 changes: 28 additions & 7 deletions output/output.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package output

import (
"fmt"

librespot "github.com/devgianlu/go-librespot"
)

Expand Down Expand Up @@ -28,6 +30,9 @@ type Output interface {
}

type NewOutputOptions struct {
// Backend is the audio backend to use (also, pulseaudio, etc).
Backend string

// Reader provides data for the output device.
//
// The format of data is as follows:
Expand All @@ -50,22 +55,28 @@ type NewOutputOptions struct {

// Device specifies the audio device name.
//
// This feature is support only for the unix driver.
// This feature is support only for the alsa backend.
Device string

// Mixer specifies the audio mixer name.
//
// This feature is support only for the unix driver.
// This feature is support only for the alsa backend.
Mixer string
// Control specifies the mixer control name
//
// This only works in combination with Mixer
Control string

// InitialVolume specifies the initial output volume.
//
// This is only supported on the alsa backend. The PulseAudio backend uses
// the PulseAudio default volume.
InitialVolume float32

// ExternalVolume specifies, if the volume is controlled outside of the app.
//
// This is only supported on the alsa backend. The PulseAudio backend always
// uses external volume.
ExternalVolume bool

// VolumeUpdate is a channel on which volume updates will be sent back to
Expand All @@ -76,12 +87,22 @@ type NewOutputOptions struct {
}

func NewOutput(options *NewOutputOptions) (Output, error) {
out, err := newAlsaOutput(options.Reader, options.SampleRate, options.ChannelCount, options.Device, options.Mixer, options.Control, options.InitialVolume, options.ExternalVolume, options.VolumeUpdate)
if err != nil {
return nil, err
switch options.Backend {
case "alsa":
out, err := newAlsaOutput(options.Reader, options.SampleRate, options.ChannelCount, options.Device, options.Mixer, options.Control, options.InitialVolume, options.ExternalVolume, options.VolumeUpdate)
if err != nil {
return nil, err
}
return out, nil
case "pulseaudio":
out, err := newPulseAudioOutput(options.Reader, options.SampleRate, options.ChannelCount, options.VolumeUpdate)
if err != nil {
return nil, err
}
return out, nil
default:
return nil, fmt.Errorf("unknown audio backend: %s", options.Backend)
}

return out, nil
}

// Do a non-blocking send on the channel to send a volume update.
Expand Down
3 changes: 2 additions & 1 deletion player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ type playerCmdDataSet struct {
drop bool
}

func NewPlayer(sp *spclient.Spclient, audioKey *audio.KeyProvider, normalisationEnabled bool, normalisationPregain float32, countryCode *string, device, mixer string, control string, volumeSteps uint32, externalVolume bool, volumeUpdate chan float32) (*Player, error) {
func NewPlayer(sp *spclient.Spclient, audioKey *audio.KeyProvider, normalisationEnabled bool, normalisationPregain float32, countryCode *string, backend, device, mixer string, control string, volumeSteps uint32, externalVolume bool, volumeUpdate chan float32) (*Player, error) {
p := &Player{
sp: sp,
audioKey: audioKey,
Expand All @@ -72,6 +72,7 @@ func NewPlayer(sp *spclient.Spclient, audioKey *audio.KeyProvider, normalisation
countryCode: countryCode,
newOutput: func(reader librespot.Float32Reader, volume float32) (output.Output, error) {
return output.NewOutput(&output.NewOutputOptions{
Backend: backend,
Reader: reader,
SampleRate: SampleRate,
ChannelCount: Channels,
Expand Down

0 comments on commit cf97e9e

Please sign in to comment.