From 599e2c1111e6f33f75da5a81f68407f2220bc907 Mon Sep 17 00:00:00 2001 From: Kevin Sumner Date: Sun, 12 Jul 2026 23:13:56 -0400 Subject: [PATCH] feat: support native playback on macOS --- README.md | 26 ++++++++----- context/context.go | 7 +++- go.mod | 4 +- go.sum | 8 +++- gomod2nix.toml | 10 ++++- main.go | 6 +++ player/player.go | 41 ++++---------------- player/player_darwin.go | 84 ++++++++++++++++++++++++++++++++++++++++ player/player_default.go | 27 +++++++++++++ player/player_pulse.go | 39 +++++++++++++++++++ player/player_test.go | 12 ++++++ 11 files changed, 213 insertions(+), 51 deletions(-) create mode 100644 player/player_darwin.go create mode 100644 player/player_default.go create mode 100644 player/player_pulse.go create mode 100644 player/player_test.go diff --git a/README.md b/README.md index 9c0b66a..f89e0bd 100644 --- a/README.md +++ b/README.md @@ -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" ``` - - diff --git a/context/context.go b/context/context.go index 7849dd9..30a5f77 100644 --- a/context/context.go +++ b/context/context.go @@ -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 diff --git a/go.mod b/go.mod index 1dadac3..3136a4f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index fa3183d..ed301cf 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/gomod2nix.toml b/gomod2nix.toml index 2e886b5..8ef3ff4 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -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=" diff --git a/main.go b/main.go index bf002ad..4f86c04 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/player/player.go b/player/player.go index d86798d..4fc594c 100644 --- a/player/player.go +++ b/player/player.go @@ -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) { diff --git a/player/player_darwin.go b/player/player_darwin.go new file mode 100644 index 0000000..4e0ef1f --- /dev/null +++ b/player/player_darwin.go @@ -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() +} diff --git a/player/player_default.go b/player/player_default.go new file mode 100644 index 0000000..ffd40fd --- /dev/null +++ b/player/player_default.go @@ -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) +} diff --git a/player/player_pulse.go b/player/player_pulse.go new file mode 100644 index 0000000..9f1461f --- /dev/null +++ b/player/player_pulse.go @@ -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() +} diff --git a/player/player_test.go b/player/player_test.go new file mode 100644 index 0000000..0056eb3 --- /dev/null +++ b/player/player_test.go @@ -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") + } +}