This commit is contained in:
Kevin Sumner 2026-07-13 00:08:17 -04:00 committed by GitHub
commit 96cf0d466b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 213 additions and 51 deletions

View file

@ -93,23 +93,31 @@ if they appear in your remote favorites.
## Dependencies
### PulseAudio
### Audio
Both linux and MacOS depend on pulseaudio to be running.
Linux uses PulseAudio. On Debian / Ubuntu, install it with:
#### MacOS
`apt install pulseaudio`
By default, pulseaudio on MacOS runs as "root", which is not ideal. PulseAudio is best run by non-root users. By symbolically linking the pulseaudio plist file into your user's `~/Library/LaunchAgents/`, it runs as your user.
macOS uses the native Core Audio output by default and requires no additional audio service.
To use a running PulseAudio server instead, start `di-tui` with
`--audio-provider pulse`. Switch back with `--audio-provider coreaudio`.
```
#### PulseAudio on MacOS
Install PulseAudio with Homebrew and run it as your user:
```sh
brew install pulseaudio
ln -s $(brew info pulseaudio | grep "/usr/local/Cellar" | awk '{print $1}')//homebrew.mxcl.pulseaudio.plist ~/Library/LaunchAgents
brew services start pulseaudio
```
#### Debian / Ubuntu
Running `brew services` without `sudo` registers PulseAudio in your user's
`~/Library/LaunchAgents` and starts it again when you log in. Check its status with:
`apt install pulseaudio`
```sh
brew services info pulseaudio
```
## MPRIS/D-bus support
@ -146,5 +154,3 @@ theme:
primary_text_color: "#969896"
secondary_text_color: "#81a2be"
```

View file

@ -7,9 +7,12 @@ import (
"github.com/acaloiaro/di-tui/components"
"github.com/acaloiaro/di-tui/views"
"github.com/jfreymuth/pulse"
)
type Player interface {
Close()
}
// CreateAppContext creates the application context
func CreateAppContext(vc *views.ViewContext) *AppContext {
ctx := &AppContext{
@ -38,7 +41,7 @@ type AppContext struct {
Network *components.Network
HighlightedChannel *components.ChannelItem
IsPlaying bool
Player *pulse.PlaybackStream
Player Player
ShowStatus bool // The status pane will be visible when true
StatusChannel chan components.StatusMessage
StreamCancel c.CancelFunc

4
go.mod
View file

@ -3,6 +3,7 @@ module github.com/acaloiaro/di-tui
go 1.21
require (
github.com/ebitengine/oto/v3 v3.3.3
github.com/gdamore/tcell/v2 v2.6.0
github.com/godbus/dbus/v5 v5.1.0
github.com/hajimehoshi/go-mp3 v0.2.1
@ -16,6 +17,7 @@ require (
)
require (
github.com/ebitengine/purego v0.8.0 // indirect
github.com/fsnotify/fsnotify v1.4.7 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect
@ -32,7 +34,7 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/testify v1.4.0 // indirect
github.com/subosito/gotenv v1.2.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.5.0 // indirect
golang.org/x/text v0.8.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect

8
go.sum
View file

@ -19,6 +19,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/ebitengine/oto/v3 v3.3.3 h1:m6RV69OqoXYSWCDsHXN9rc07aDuDstGHtait7HXSM7g=
github.com/ebitengine/oto/v3 v3.3.3/go.mod h1:MZeb/lwoC4DCOdiTIxYezrURTw7EvK/yF863+tmBI+U=
github.com/ebitengine/purego v0.8.0 h1:JbqvnEzRvPpxhCJzJJ2y0RbiZ8nyjccVUrSM3q+GvvE=
github.com/ebitengine/purego v0.8.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
@ -177,8 +181,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=

View file

@ -1,6 +1,12 @@
schema = 3
[mod]
[mod."github.com/ebitengine/oto/v3"]
version = "v3.3.3"
hash = "sha256-HH9bSlKzEQU2S4/VqdjxKZxWTqYgl3Z3Zaf3rSHZ1zs="
[mod."github.com/ebitengine/purego"]
version = "v0.8.0"
hash = "sha256-fM+5HYIKDPLDrvv2f9/WQG0F20+o0+At0AbPQtF2JP0="
[mod."github.com/fsnotify/fsnotify"]
version = "v1.4.7"
hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8="
@ -77,8 +83,8 @@ schema = 3
version = "v1.2.0"
hash = "sha256-RUsfBl9xvHk8H6SPwiLi/BpHjkyO/YLvlFmRfGRIW1U="
[mod."golang.org/x/sys"]
version = "v0.6.0"
hash = "sha256-zAgxiTuL24sGhbXrna9R1UYqLQh46ldztpumOScmduY="
version = "v0.25.0"
hash = "sha256-PXZ9EQZ7SFpcL7d3E1+KGTxziYlHEIZPfoXEbnaVD3I="
[mod."golang.org/x/term"]
version = "v0.5.0"
hash = "sha256-f3DiX7NkDsEZpPS+PbmnOH9F5WHFZ1sQrfFg/T2UPno="

View file

@ -12,6 +12,7 @@ import (
"github.com/acaloiaro/di-tui/context"
"github.com/acaloiaro/di-tui/difm"
"github.com/acaloiaro/di-tui/mpris"
"github.com/acaloiaro/di-tui/player"
"github.com/acaloiaro/di-tui/views"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
@ -30,6 +31,7 @@ func main() {
passwordFlag := pflag.String("password", "", "your di.fm password")
versionFlag := pflag.Bool("version", false, "print the current di-tui version")
networkFlag := pflag.String("network", viper.GetString("network.shortname"), "the audioaddict network to connect to")
audioProviderFlag := pflag.String("audio-provider", player.DefaultAudioProvider(), "audio output provider (coreaudio or pulse on macOS; pulse elsewhere)")
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)
@ -37,6 +39,10 @@ func main() {
fmt.Printf("di-tui %s\n", VERSION)
return
}
if err = player.ConfigureAudioProvider(*audioProviderFlag); err != nil {
fmt.Println(err)
return
}
network, err := difm.GetNetwork(*networkFlag)
if err != nil {
var networks []string

View file

@ -1,45 +1,18 @@
package player
import (
"io"
"github.com/acaloiaro/di-tui/context"
"github.com/hajimehoshi/go-mp3"
"github.com/jfreymuth/pulse"
"github.com/jfreymuth/pulse/proto"
)
func Play(ctx *context.AppContext, stream io.ReadWriter, playbackLatency int) (err error) {
var d *mp3.Decoder
d, err = mp3.NewDecoder(stream)
if err != nil {
return
}
const (
CoreAudioProvider = "coreaudio"
PulseProvider = "pulse"
)
var c *pulse.Client
c, err = pulse.NewClient()
if err != nil {
return
}
defer c.Close()
var audioProvider = defaultAudioProvider
ctx.Player, err = c.NewPlayback(
// proto.FormatInt16LE convinces `pulse` to expect 2 bytes per sample; the format that go-mp3 lays out bytes
pulse.NewReader(d, proto.FormatInt16LE),
pulse.PlaybackSampleRate(d.SampleRate()),
pulse.PlaybackStereo,
pulse.PlaybackLatency(float64(playbackLatency)),
)
if err != nil {
return
}
ctx.Player.Start()
ctx.Player.Drain()
err = ctx.Player.Error()
return
func DefaultAudioProvider() string {
return defaultAudioProvider
}
func Stop(ctx *context.AppContext) {

84
player/player_darwin.go Normal file
View file

@ -0,0 +1,84 @@
//go:build darwin
// This is the macOS audio dispatcher. It defaults to native Core Audio while
// allowing the user to select the shared PulseAudio backend at runtime.
package player
import (
"fmt"
"io"
"sync"
"time"
"github.com/acaloiaro/di-tui/context"
"github.com/ebitengine/oto/v3"
"github.com/hajimehoshi/go-mp3"
)
var (
audioContext *oto.Context
audioContextErr error
audioContextOnce sync.Once
audioSampleRate int
)
const defaultAudioProvider = CoreAudioProvider
type darwinPlayer struct {
*oto.Player
}
func (p *darwinPlayer) Close() {
_ = p.Player.Close()
}
func ConfigureAudioProvider(provider string) error {
switch provider {
case CoreAudioProvider, PulseProvider:
audioProvider = provider
return nil
default:
return fmt.Errorf("unsupported audio provider %q (choose %s or %s)", provider, CoreAudioProvider, PulseProvider)
}
}
func Play(ctx *context.AppContext, stream io.ReadWriter, playbackLatency int) error {
if audioProvider == PulseProvider {
return playPulse(ctx, stream, playbackLatency)
}
return playCoreAudio(ctx, stream)
}
func playCoreAudio(ctx *context.AppContext, stream io.ReadWriter) error {
d, err := mp3.NewDecoder(stream)
if err != nil {
return err
}
audioContextOnce.Do(func() {
audioSampleRate = d.SampleRate()
var ready chan struct{}
audioContext, ready, audioContextErr = oto.NewContext(&oto.NewContextOptions{
SampleRate: audioSampleRate,
ChannelCount: 2,
Format: oto.FormatSignedInt16LE,
})
if audioContextErr == nil {
<-ready
}
})
if audioContextErr != nil {
return audioContextErr
}
if d.SampleRate() != audioSampleRate {
return fmt.Errorf("audio sample rate changed from %d Hz to %d Hz", audioSampleRate, d.SampleRate())
}
p := &darwinPlayer{Player: audioContext.NewPlayer(d)}
ctx.Player = p
p.Play()
for p.IsPlaying() {
time.Sleep(10 * time.Millisecond)
}
return p.Err()
}

27
player/player_default.go Normal file
View file

@ -0,0 +1,27 @@
//go:build !darwin
// This is the default audio dispatcher. It makes PulseAudio the only available
// provider and supplies the platform-specific functions shared code calls.
// macOS has a specialized implementation that overrides this one.
package player
import (
"fmt"
"io"
"github.com/acaloiaro/di-tui/context"
)
const defaultAudioProvider = PulseProvider
func ConfigureAudioProvider(provider string) error {
if provider != PulseProvider {
return fmt.Errorf("unsupported audio provider %q (choose %s)", provider, PulseProvider)
}
audioProvider = provider
return nil
}
func Play(ctx *context.AppContext, stream io.ReadWriter, playbackLatency int) error {
return playPulse(ctx, stream, playbackLatency)
}

39
player/player_pulse.go Normal file
View file

@ -0,0 +1,39 @@
package player
import (
"io"
"github.com/acaloiaro/di-tui/context"
"github.com/hajimehoshi/go-mp3"
"github.com/jfreymuth/pulse"
"github.com/jfreymuth/pulse/proto"
)
func playPulse(ctx *context.AppContext, stream io.ReadWriter, playbackLatency int) error {
d, err := mp3.NewDecoder(stream)
if err != nil {
return err
}
c, err := pulse.NewClient()
if err != nil {
return err
}
defer c.Close()
p, err := c.NewPlayback(
// proto.FormatInt16LE convinces pulse to expect the format emitted by go-mp3.
pulse.NewReader(d, proto.FormatInt16LE),
pulse.PlaybackSampleRate(d.SampleRate()),
pulse.PlaybackStereo,
pulse.PlaybackLatency(float64(playbackLatency)),
)
if err != nil {
return err
}
ctx.Player = p
p.Start()
p.Drain()
return p.Error()
}

12
player/player_test.go Normal file
View file

@ -0,0 +1,12 @@
package player
import "testing"
func TestConfigureAudioProvider(t *testing.T) {
if err := ConfigureAudioProvider(DefaultAudioProvider()); err != nil {
t.Fatalf("default provider was rejected: %v", err)
}
if err := ConfigureAudioProvider("unknown"); err == nil {
t.Fatal("unknown provider was accepted")
}
}